Jelajahi Sumber

Initial implementation of Smartbotic-MicroBit platform

Multi-tenant SaaS wrapping CallerAI voice AI platform with:
- C++20 backend: JWT auth, workspace/invitation management, CallerAI
  integration, calendar/appointment system, 50+ REST API endpoints
- React 19 frontend: Vite 7, TypeScript, Tailwind v4, Zustand,
  TanStack Query with full SPA (login, workspaces, assistants,
  calendars, settings)
- CMake build system with FetchContent deps (cpp-httplib, libbcrypt,
  nlohmann_json), smartbotic-database submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Void User 2 bulan lalu
melakukan
eac393a1ec
100 mengubah file dengan 13184 tambahan dan 0 penghapusan
  1. 29 0
      .gitignore
  2. 3 0
      .gitmodules
  3. 46 0
      CMakeLists.txt
  4. 59 0
      cmake/CompilerFlags.cmake
  5. 59 0
      cmake/Dependencies.cmake
  6. 39 0
      cmake/FindPackages.cmake
  7. 58 0
      config/microbit.json
  8. 67 0
      config/migrations/001_core_collections.json
  9. 19 0
      config/migrations/002_global_settings.json
  10. 28 0
      config/storage.json
  11. 1 0
      external/smartbotic-database
  12. 4 0
      lib/CMakeLists.txt
  13. 21 0
      lib/auth/CMakeLists.txt
  14. 60 0
      lib/auth/include/smartbotic/microbit/auth/auth_middleware.hpp
  15. 17 0
      lib/auth/include/smartbotic/microbit/auth/bcrypt_utils.hpp
  16. 65 0
      lib/auth/include/smartbotic/microbit/auth/jwt_utils.hpp
  17. 141 0
      lib/auth/src/auth_middleware.cpp
  18. 40 0
      lib/auth/src/bcrypt_utils.cpp
  19. 217 0
      lib/auth/src/jwt_utils.cpp
  20. 18 0
      lib/callerai/CMakeLists.txt
  21. 52 0
      lib/callerai/include/smartbotic/microbit/callerai/callerai_client.hpp
  22. 119 0
      lib/callerai/src/callerai_client.cpp
  23. 17 0
      lib/common/CMakeLists.txt
  24. 62 0
      lib/common/include/smartbotic/microbit/common/config_loader.hpp
  25. 10 0
      lib/common/include/smartbotic/microbit/common/logging.hpp
  26. 28 0
      lib/common/include/smartbotic/microbit/common/utils.hpp
  27. 84 0
      lib/common/src/config_loader.cpp
  28. 14 0
      lib/common/src/logging.cpp
  29. 130 0
      lib/common/src/utils.cpp
  30. 14 0
      lib/smtp/CMakeLists.txt
  31. 33 0
      lib/smtp/include/smartbotic/microbit/smtp/smtp_client.hpp
  32. 141 0
      lib/smtp/src/smtp_client.cpp
  33. 49 0
      src/CMakeLists.txt
  34. 196 0
      src/app.cpp
  35. 104 0
      src/app.hpp
  36. 57 0
      src/main.cpp
  37. 456 0
      src/routes/assistant_routes.cpp
  38. 11 0
      src/routes/assistant_routes.hpp
  39. 327 0
      src/routes/auth_routes.cpp
  40. 11 0
      src/routes/auth_routes.hpp
  41. 755 0
      src/routes/calendar_routes.cpp
  42. 11 0
      src/routes/calendar_routes.hpp
  43. 301 0
      src/routes/invitation_routes.cpp
  44. 11 0
      src/routes/invitation_routes.hpp
  45. 215 0
      src/routes/settings_routes.cpp
  46. 11 0
      src/routes/settings_routes.hpp
  47. 75 0
      src/routes/user_routes.cpp
  48. 11 0
      src/routes/user_routes.hpp
  49. 425 0
      src/routes/workspace_routes.cpp
  50. 11 0
      src/routes/workspace_routes.hpp
  51. 235 0
      src/services/assistant_service.cpp
  52. 48 0
      src/services/assistant_service.hpp
  53. 191 0
      src/services/auth_service.cpp
  54. 50 0
      src/services/auth_service.hpp
  55. 273 0
      src/services/calendar_service.cpp
  56. 49 0
      src/services/calendar_service.hpp
  57. 234 0
      src/services/invitation_service.cpp
  58. 45 0
      src/services/invitation_service.hpp
  59. 113 0
      src/services/workspace_service.cpp
  60. 33 0
      src/services/workspace_service.hpp
  61. 103 0
      src/stores/assistant_store.cpp
  62. 48 0
      src/stores/assistant_store.hpp
  63. 260 0
      src/stores/calendar_store.cpp
  64. 93 0
      src/stores/calendar_store.hpp
  65. 119 0
      src/stores/invitation_store.cpp
  66. 48 0
      src/stores/invitation_store.hpp
  67. 27 0
      src/stores/settings_store.cpp
  68. 27 0
      src/stores/settings_store.hpp
  69. 186 0
      src/stores/user_store.cpp
  70. 66 0
      src/stores/user_store.hpp
  71. 179 0
      src/stores/workspace_store.cpp
  72. 64 0
      src/stores/workspace_store.hpp
  73. 12 0
      webui/index.html
  74. 2464 0
      webui/package-lock.json
  75. 29 0
      webui/package.json
  76. 57 0
      webui/src/App.tsx
  77. 477 0
      webui/src/api/client.ts
  78. 12 0
      webui/src/components/auth/ProtectedRoute.tsx
  79. 128 0
      webui/src/components/layout/Header.tsx
  80. 56 0
      webui/src/components/layout/MainLayout.tsx
  81. 87 0
      webui/src/components/layout/Sidebar.tsx
  82. 32 0
      webui/src/components/ui/Badge.tsx
  83. 62 0
      webui/src/components/ui/Button.tsx
  84. 29 0
      webui/src/components/ui/Card.tsx
  85. 45 0
      webui/src/components/ui/Input.tsx
  86. 50 0
      webui/src/components/ui/Modal.tsx
  87. 37 0
      webui/src/components/ui/Spinner.tsx
  88. 1 0
      webui/src/index.css
  89. 26 0
      webui/src/main.tsx
  90. 291 0
      webui/src/pages/assistants/AssistantEditor.tsx
  91. 240 0
      webui/src/pages/assistants/AssistantsList.tsx
  92. 102 0
      webui/src/pages/auth/LoginPage.tsx
  93. 124 0
      webui/src/pages/auth/RegisterPage.tsx
  94. 228 0
      webui/src/pages/calendar/AppointmentsList.tsx
  95. 225 0
      webui/src/pages/calendar/CalendarManager.tsx
  96. 304 0
      webui/src/pages/calendar/TimeSlotsEditor.tsx
  97. 154 0
      webui/src/pages/dashboard/Dashboard.tsx
  98. 117 0
      webui/src/pages/invitations/PendingInvitations.tsx
  99. 249 0
      webui/src/pages/members/MembersList.tsx
  100. 323 0
      webui/src/pages/settings/GlobalSettings.tsx

+ 29 - 0
.gitignore

@@ -0,0 +1,29 @@
+# Build
+build/
+cmake-build-*/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+compile_commands.json
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Node
+webui/node_modules/
+webui/dist/
+webui/.vite/
+
+# Config overrides
+config/local.json
+.env
+.env.*
+
+# Data
+/data/
+*.key

+ 3 - 0
.gitmodules

@@ -0,0 +1,3 @@
+[submodule "external/smartbotic-database"]
+	path = external/smartbotic-database
+	url = ssh://git@git.smartbotics.ai:10022/fszontagh/smartbotic-database.git

+ 46 - 0
CMakeLists.txt

@@ -0,0 +1,46 @@
+cmake_minimum_required(VERSION 3.20)
+project(smartbotic-microbit VERSION 0.1.0 LANGUAGES CXX C)
+
+set(CMAKE_CXX_STANDARD 20)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+set(CMAKE_CXX_EXTENSIONS OFF)
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+
+if(NOT CMAKE_BUILD_TYPE)
+    set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Build type" FORCE)
+endif()
+
+option(BUILD_TESTS "Build tests" OFF)
+option(BUNDLE_DATABASE "Include smartbotic-database service in build" OFF)
+
+list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
+include(CompilerFlags)
+include(FindPackages)
+include(Dependencies)
+
+# Database submodule
+set(SMARTBOTIC_DB_BUILD_CLIENT ON CACHE BOOL "" FORCE)
+set(SMARTBOTIC_DB_BUILD_SERVICE ${BUNDLE_DATABASE} CACHE BOOL "" FORCE)
+if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/smartbotic-database/CMakeLists.txt")
+    add_subdirectory(external/smartbotic-database)
+else()
+    message(FATAL_ERROR "smartbotic-database submodule not found. Run: git submodule update --init --recursive")
+endif()
+
+# Libraries
+add_subdirectory(lib)
+
+# Main application
+add_subdirectory(src)
+
+# Tests
+if(BUILD_TESTS)
+    enable_testing()
+    add_subdirectory(tests)
+endif()
+
+# Install
+include(GNUInstallDirs)
+install(TARGETS smartbotic-microbit RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})
+install(DIRECTORY config/ DESTINATION ${CMAKE_INSTALL_SYSCONFDIR}/smartbotic-microbit
+    FILES_MATCHING PATTERN "*.json")

+ 59 - 0
cmake/CompilerFlags.cmake

@@ -0,0 +1,59 @@
+# Smartbotic-MicroBit Compiler Flags
+
+# Common warnings for all builds
+add_compile_options(
+    -Wall
+    -Wextra
+    -Wpedantic
+    -Werror=return-type
+    -Werror=unused-result
+    -Wno-unused-parameter
+)
+
+# Debug flags
+if(CMAKE_BUILD_TYPE STREQUAL "Debug")
+    add_compile_options(
+        -g3
+        -O0
+        -fno-omit-frame-pointer
+    )
+
+    option(ENABLE_SANITIZERS "Enable ASan and UBSan" OFF)
+    if(ENABLE_SANITIZERS)
+        add_compile_options(-fsanitize=address,undefined)
+        add_link_options(-fsanitize=address,undefined)
+    endif()
+endif()
+
+# Release flags
+if(CMAKE_BUILD_TYPE STREQUAL "Release")
+    add_compile_options(
+        -O3
+        -DNDEBUG
+        -march=native
+    )
+endif()
+
+# RelWithDebInfo flags
+if(CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo")
+    add_compile_options(
+        -O2
+        -g
+        -DNDEBUG
+    )
+endif()
+
+# LTO for release builds
+if(CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo")
+    include(CheckIPOSupported)
+    check_ipo_supported(RESULT IPO_SUPPORTED OUTPUT IPO_ERROR)
+    if(IPO_SUPPORTED)
+        message(STATUS "IPO/LTO enabled")
+        set(CMAKE_INTERPROCEDURAL_OPTIMIZATION ON)
+    else()
+        message(STATUS "IPO/LTO not supported: ${IPO_ERROR}")
+    endif()
+endif()
+
+# Position independent code
+set(CMAKE_POSITION_INDEPENDENT_CODE ON)

+ 59 - 0
cmake/Dependencies.cmake

@@ -0,0 +1,59 @@
+# Smartbotic-MicroBit External Dependencies
+# Fetch non-apt packages via CMake FetchContent
+
+include(FetchContent)
+
+# cpp-httplib (HTTP server and client)
+# Header-only HTTP/HTTPS library
+FetchContent_Declare(
+    httplib
+    GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git
+    GIT_TAG        v0.18.3
+)
+
+# libbcrypt (Password hashing)
+FetchContent_Declare(
+    libbcrypt
+    GIT_REPOSITORY https://github.com/trusch/libbcrypt.git
+    GIT_TAG        master
+    GIT_SHALLOW    TRUE
+)
+
+# nlohmann_json (if not found on system)
+if(NOT nlohmann_json_FOUND)
+    message(STATUS "Fetching nlohmann_json...")
+    FetchContent_Declare(
+        nlohmann_json
+        GIT_REPOSITORY https://github.com/nlohmann/json.git
+        GIT_TAG        v3.11.3
+        GIT_SHALLOW    TRUE
+        OVERRIDE_FIND_PACKAGE
+    )
+    set(JSON_BuildTests OFF CACHE BOOL "" FORCE)
+    FetchContent_MakeAvailable(nlohmann_json)
+endif()
+
+# Make httplib available (header-only)
+message(STATUS "Fetching cpp-httplib...")
+set(HTTPLIB_REQUIRE_OPENSSL ON CACHE BOOL "" FORCE)
+set(HTTPLIB_USE_OPENSSL_IF_AVAILABLE ON CACHE BOOL "" FORCE)
+FetchContent_MakeAvailable(httplib)
+
+# Make libbcrypt available (manual build due to outdated CMakeLists.txt)
+message(STATUS "Fetching libbcrypt...")
+FetchContent_GetProperties(libbcrypt)
+if(NOT libbcrypt_POPULATED)
+    FetchContent_Populate(libbcrypt)
+    add_library(bcrypt STATIC
+        ${libbcrypt_SOURCE_DIR}/src/bcrypt.c
+        ${libbcrypt_SOURCE_DIR}/src/crypt_blowfish.c
+        ${libbcrypt_SOURCE_DIR}/src/crypt_gensalt.c
+        ${libbcrypt_SOURCE_DIR}/src/wrapper.c
+    )
+    target_include_directories(bcrypt PUBLIC
+        ${libbcrypt_SOURCE_DIR}/include
+        ${libbcrypt_SOURCE_DIR}/include/bcrypt
+    )
+    target_compile_options(bcrypt PRIVATE -w)
+    set_target_properties(bcrypt PROPERTIES INTERPROCEDURAL_OPTIMIZATION OFF)
+endif()

+ 39 - 0
cmake/FindPackages.cmake

@@ -0,0 +1,39 @@
+# Smartbotic-MicroBit System Package Discovery
+
+find_package(PkgConfig REQUIRED)
+
+# gRPC and Protobuf (for smartbotic-database client)
+find_package(Protobuf REQUIRED)
+message(STATUS "Found Protobuf: ${Protobuf_VERSION}")
+
+find_package(gRPC CONFIG QUIET)
+if(gRPC_FOUND)
+    message(STATUS "Found gRPC via CMake config")
+else()
+    pkg_check_modules(GRPC REQUIRED IMPORTED_TARGET grpc++ grpc)
+    message(STATUS "Found gRPC via pkg-config: ${GRPC_VERSION}")
+endif()
+
+# libcurl (HTTP client for CallerAI API + SMTP)
+find_package(CURL REQUIRED)
+message(STATUS "Found CURL: ${CURL_VERSION_STRING}")
+
+# OpenSSL (TLS, JWT HMAC-SHA256)
+find_package(OpenSSL REQUIRED)
+message(STATUS "Found OpenSSL: ${OPENSSL_VERSION}")
+
+# nlohmann_json (may be provided via FetchContent in Dependencies.cmake)
+find_package(nlohmann_json 3.2.0 QUIET)
+if(nlohmann_json_FOUND)
+    message(STATUS "Found nlohmann_json: ${nlohmann_json_VERSION}")
+endif()
+
+# spdlog (logging)
+find_package(spdlog REQUIRED)
+message(STATUS "Found spdlog: ${spdlog_VERSION}")
+
+# systemd (optional, for sd_notify)
+pkg_check_modules(SYSTEMD QUIET IMPORTED_TARGET libsystemd)
+if(SYSTEMD_FOUND)
+    message(STATUS "Found systemd: ${SYSTEMD_VERSION}")
+endif()

+ 58 - 0
config/microbit.json

@@ -0,0 +1,58 @@
+{
+    "log_level": "info",
+
+    "database": {
+        "rpc_address": "${DB_ADDRESS:localhost:9004}",
+        "timeout_ms": 5000,
+        "max_retries": 3
+    },
+
+    "http": {
+        "bind_address": "0.0.0.0",
+        "port": 8090,
+        "static_files": {
+            "enabled": true,
+            "path": "${WEBUI_PATH:webui/dist}"
+        }
+    },
+
+    "auth": {
+        "enabled": true,
+        "jwt_secret": "${JWT_SECRET:change-me-in-production}",
+        "access_token_lifetime_sec": 900,
+        "refresh_token_lifetime_sec": 604800,
+        "password_policy": {
+            "min_length": 8,
+            "require_number": true,
+            "require_special": false
+        }
+    },
+
+    "smtp": {
+        "host": "${SMTP_HOST:}",
+        "port": "${SMTP_PORT:587}",
+        "username": "${SMTP_USER:}",
+        "password": "${SMTP_PASSWORD:}",
+        "from_address": "${SMTP_FROM:noreply@smartbotics.ai}",
+        "from_name": "Smartbotic",
+        "use_tls": true
+    },
+
+    "callerai": {
+        "api_url": "${CALLERAI_API_URL:http://localhost:8080}",
+        "api_key": "${CALLERAI_API_KEY:}",
+        "timeout_sec": 30
+    },
+
+    "cors": {
+        "enabled": true,
+        "origins": ["*"],
+        "methods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
+        "headers": ["Authorization", "Content-Type", "X-Requested-With"]
+    },
+
+    "rate_limit": {
+        "enabled": true,
+        "requests_per_minute": 200
+    }
+}

+ 67 - 0
config/migrations/001_core_collections.json

@@ -0,0 +1,67 @@
+{
+    "version": "001",
+    "name": "core_collections",
+    "description": "Create smartbotic-microbit core collections",
+    "operations": [
+        {
+            "type": "create_collection",
+            "collection": "users",
+            "options": {
+                "encrypted": true,
+                "sensitive_fields": ["password_hash"]
+            }
+        },
+        {
+            "type": "create_collection",
+            "collection": "sessions",
+            "options": {
+                "default_ttl_seconds": 604800
+            }
+        },
+        {
+            "type": "create_collection",
+            "collection": "workspaces",
+            "options": {}
+        },
+        {
+            "type": "create_collection",
+            "collection": "workspace_members",
+            "options": {}
+        },
+        {
+            "type": "create_collection",
+            "collection": "invitations",
+            "options": {
+                "default_ttl_seconds": 604800
+            }
+        },
+        {
+            "type": "create_collection",
+            "collection": "assistants",
+            "options": {}
+        },
+        {
+            "type": "create_collection",
+            "collection": "calendars",
+            "options": {}
+        },
+        {
+            "type": "create_collection",
+            "collection": "time_slots",
+            "options": {}
+        },
+        {
+            "type": "create_collection",
+            "collection": "appointments",
+            "options": {}
+        },
+        {
+            "type": "create_collection",
+            "collection": "settings",
+            "options": {
+                "encrypted": true,
+                "sensitive_fields": ["smtp_password", "callerai_api_key"]
+            }
+        }
+    ]
+}

+ 19 - 0
config/migrations/002_global_settings.json

@@ -0,0 +1,19 @@
+{
+    "version": "002",
+    "name": "global_settings",
+    "description": "Initialize global settings document",
+    "operations": [
+        {
+            "type": "insert_if_not_exists",
+            "collection": "settings",
+            "id": "global",
+            "data": {
+                "initialized": true,
+                "features": {
+                    "registration_open": true,
+                    "invitation_expiry_hours": 168
+                }
+            }
+        }
+    ]
+}

+ 28 - 0
config/storage.json

@@ -0,0 +1,28 @@
+{
+    "log_level": "info",
+    "database": {
+        "bind_address": "0.0.0.0",
+        "rpc_port": 9004,
+        "node_id": "microbit-db-1",
+        "data_directory": "${DB_DATA_DIR:/var/lib/smartbotic-microbit/storage}",
+        "migrations": {
+            "enabled": true,
+            "directory": "${DB_MIGRATIONS_DIR:config/migrations}",
+            "auto_apply": true,
+            "fail_on_error": true
+        },
+        "persistence": {
+            "wal_sync_interval_ms": 100,
+            "snapshot_interval_sec": 3600,
+            "compression": "lz4"
+        },
+        "encryption": {
+            "enabled": true,
+            "key_file": "${DB_KEY_FILE:/var/lib/smartbotic-microbit/storage/storage.key}",
+            "auto_generate_key": true
+        },
+        "replication": {
+            "enabled": false
+        }
+    }
+}

+ 1 - 0
external/smartbotic-database

@@ -0,0 +1 @@
+Subproject commit 8464b0179f80b79a02db186e3c314a03a7526a3a

+ 4 - 0
lib/CMakeLists.txt

@@ -0,0 +1,4 @@
+add_subdirectory(common)
+add_subdirectory(auth)
+add_subdirectory(smtp)
+add_subdirectory(callerai)

+ 21 - 0
lib/auth/CMakeLists.txt

@@ -0,0 +1,21 @@
+add_library(microbit_auth STATIC
+    src/jwt_utils.cpp
+    src/bcrypt_utils.cpp
+    src/auth_middleware.cpp
+)
+
+target_include_directories(microbit_auth PUBLIC
+    ${CMAKE_CURRENT_SOURCE_DIR}/include
+)
+
+target_link_libraries(microbit_auth PUBLIC
+    microbit_common
+    httplib
+    bcrypt
+    OpenSSL::SSL
+    OpenSSL::Crypto
+)
+
+target_compile_definitions(microbit_auth PUBLIC CPPHTTPLIB_OPENSSL_SUPPORT)
+
+add_library(smartbotic::microbit::auth ALIAS microbit_auth)

+ 60 - 0
lib/auth/include/smartbotic/microbit/auth/auth_middleware.hpp

@@ -0,0 +1,60 @@
+#pragma once
+
+#include "jwt_utils.hpp"
+
+#include <httplib.h>
+#include <nlohmann/json.hpp>
+
+#include <functional>
+#include <optional>
+#include <string>
+#include <unordered_set>
+
+namespace smartbotic::microbit::auth {
+
+using json = nlohmann::json;
+
+struct AuthContext {
+    std::string userId;
+    std::string email;
+    std::string role;  // Global role from JWT (not workspace role)
+};
+
+struct AuthConfig {
+    bool enabled = true;
+    std::string jwtSecret;
+    uint32_t accessTokenLifetimeSec = 900;
+    uint32_t refreshTokenLifetimeSec = 604800;
+    uint32_t minPasswordLength = 8;
+    bool requireNumber = true;
+    bool requireSpecial = false;
+};
+
+class AuthMiddleware {
+public:
+    explicit AuthMiddleware(const AuthConfig& config);
+
+    [[nodiscard]] const AuthConfig& config() const { return config_; }
+
+    bool authenticate(const httplib::Request& req, httplib::Response& res);
+    [[nodiscard]] std::optional<AuthContext> getAuthContext() const { return lastAuthContext_; }
+    [[nodiscard]] bool isPublicPath(const std::string& path) const;
+    std::optional<AuthContext> validateToken(const std::string& token);
+
+    std::string createAccessToken(const std::string& userId, const std::string& email, const std::string& role);
+    std::string createRefreshToken(const std::string& userId, const std::string& sessionId);
+    std::string validatePassword(const std::string& password) const;
+
+    void addPublicPath(const std::string& path);
+
+private:
+    AuthConfig config_;
+    std::optional<AuthContext> lastAuthContext_;
+    std::unordered_set<std::string> publicPaths_;
+
+    std::string extractToken(const httplib::Request& req) const;
+    void setUnauthorized(httplib::Response& res, const std::string& message = "Unauthorized") const;
+    void setForbidden(httplib::Response& res, const std::string& message = "Forbidden") const;
+};
+
+} // namespace smartbotic::microbit::auth

+ 17 - 0
lib/auth/include/smartbotic/microbit/auth/bcrypt_utils.hpp

@@ -0,0 +1,17 @@
+#pragma once
+
+#include <string>
+
+namespace smartbotic::microbit::auth {
+
+class BCryptUtils {
+public:
+    static constexpr int DEFAULT_COST = 12;
+
+    static std::string hashPassword(const std::string& password, int cost = DEFAULT_COST);
+    static bool verifyPassword(const std::string& password, const std::string& hash);
+    static std::string randomHex(size_t numBytes);
+    static std::string generateId(const std::string& prefix);
+};
+
+} // namespace smartbotic::microbit::auth

+ 65 - 0
lib/auth/include/smartbotic/microbit/auth/jwt_utils.hpp

@@ -0,0 +1,65 @@
+#pragma once
+
+#include <nlohmann/json.hpp>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit::auth {
+
+using json = nlohmann::json;
+
+class JWTUtils {
+public:
+    static constexpr const char* TOKEN_TYPE_ACCESS = "access";
+    static constexpr const char* TOKEN_TYPE_REFRESH = "refresh";
+    static constexpr uint32_t DEFAULT_ACCESS_TOKEN_LIFETIME_SEC = 900;
+    static constexpr uint32_t DEFAULT_REFRESH_TOKEN_LIFETIME_SEC = 604800;
+
+    struct JWTPayload {
+        std::string sub;
+        std::string email;
+        std::string role;
+        std::string type;
+        std::string sessionId;
+        uint64_t iat = 0;
+        uint64_t exp = 0;
+        std::string jti;
+
+        json toJson() const;
+        static JWTPayload fromJson(const json& j);
+    };
+
+    static std::string createToken(const JWTPayload& payload, const std::string& secret);
+
+    static std::string createAccessToken(
+        const std::string& userId,
+        const std::string& email,
+        const std::string& role,
+        const std::string& secret,
+        uint32_t lifetimeSec = DEFAULT_ACCESS_TOKEN_LIFETIME_SEC
+    );
+
+    static std::string createRefreshToken(
+        const std::string& userId,
+        const std::string& sessionId,
+        const std::string& secret,
+        uint32_t lifetimeSec = DEFAULT_REFRESH_TOKEN_LIFETIME_SEC
+    );
+
+    static std::optional<JWTPayload> validateToken(const std::string& token, const std::string& secret);
+    static std::optional<JWTPayload> decodeToken(const std::string& token);
+    static bool isExpired(const JWTPayload& payload);
+    static uint64_t currentTimestamp();
+
+private:
+    static std::string base64UrlEncode(const std::string& input);
+    static std::string base64UrlEncode(const unsigned char* data, size_t len);
+    static std::string base64UrlDecode(const std::string& input);
+    static std::string hmacSha256(const std::string& data, const std::string& secret);
+    static std::string generateJti();
+};
+
+} // namespace smartbotic::microbit::auth

+ 141 - 0
lib/auth/src/auth_middleware.cpp

@@ -0,0 +1,141 @@
+#include "smartbotic/microbit/auth/auth_middleware.hpp"
+
+#include <spdlog/spdlog.h>
+#include <algorithm>
+#include <regex>
+
+namespace smartbotic::microbit::auth {
+
+static const std::vector<std::string> DEFAULT_PUBLIC_PATHS = {
+    "/api/v1/health",
+    "/api/v1/auth/login",
+    "/api/v1/auth/register",
+    "/api/v1/auth/refresh",
+};
+
+AuthMiddleware::AuthMiddleware(const AuthConfig& config)
+    : config_(config) {
+    for (const auto& path : DEFAULT_PUBLIC_PATHS) {
+        publicPaths_.insert(path);
+    }
+}
+
+bool AuthMiddleware::authenticate(const httplib::Request& req, httplib::Response& res) {
+    lastAuthContext_.reset();
+
+    if (!config_.enabled) {
+        AuthContext ctx;
+        ctx.userId = "system";
+        ctx.email = "system@localhost";
+        ctx.role = "owner";
+        lastAuthContext_ = ctx;
+        return true;
+    }
+
+    if (isPublicPath(req.path)) {
+        return true;
+    }
+
+    // Allow static file serving (non-API paths)
+    if (req.path.find("/api/") != 0) {
+        return true;
+    }
+
+    std::string token = extractToken(req);
+    if (token.empty()) {
+        setUnauthorized(res, "No authentication token provided");
+        return false;
+    }
+
+    auto authCtx = validateToken(token);
+    if (!authCtx) {
+        setUnauthorized(res, "Invalid or expired token");
+        return false;
+    }
+
+    lastAuthContext_ = authCtx;
+    return true;
+}
+
+bool AuthMiddleware::isPublicPath(const std::string& path) const {
+    return publicPaths_.count(path) > 0;
+}
+
+std::optional<AuthContext> AuthMiddleware::validateToken(const std::string& token) {
+    auto payload = JWTUtils::validateToken(token, config_.jwtSecret);
+    if (!payload) return std::nullopt;
+
+    if (payload->type != JWTUtils::TOKEN_TYPE_ACCESS) {
+        return std::nullopt;
+    }
+
+    AuthContext ctx;
+    ctx.userId = payload->sub;
+    ctx.email = payload->email;
+    ctx.role = payload->role;
+    return ctx;
+}
+
+std::string AuthMiddleware::createAccessToken(
+    const std::string& userId, const std::string& email, const std::string& role
+) {
+    return JWTUtils::createAccessToken(
+        userId, email, role, config_.jwtSecret, config_.accessTokenLifetimeSec);
+}
+
+std::string AuthMiddleware::createRefreshToken(
+    const std::string& userId, const std::string& sessionId
+) {
+    return JWTUtils::createRefreshToken(
+        userId, sessionId, config_.jwtSecret, config_.refreshTokenLifetimeSec);
+}
+
+std::string AuthMiddleware::validatePassword(const std::string& password) const {
+    if (password.length() < config_.minPasswordLength) {
+        return "Password must be at least " + std::to_string(config_.minPasswordLength) + " characters";
+    }
+    if (config_.requireNumber) {
+        if (!std::any_of(password.begin(), password.end(), ::isdigit)) {
+            return "Password must contain at least one number";
+        }
+    }
+    if (config_.requireSpecial) {
+        std::regex specialChars("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]");
+        if (!std::regex_search(password, specialChars)) {
+            return "Password must contain at least one special character";
+        }
+    }
+    return "";
+}
+
+void AuthMiddleware::addPublicPath(const std::string& path) {
+    publicPaths_.insert(path);
+}
+
+std::string AuthMiddleware::extractToken(const httplib::Request& req) const {
+    std::string authHeader = req.get_header_value("Authorization");
+    if (!authHeader.empty()) {
+        const std::string bearerPrefix = "Bearer ";
+        if (authHeader.compare(0, bearerPrefix.size(), bearerPrefix) == 0) {
+            return authHeader.substr(bearerPrefix.size());
+        }
+        return authHeader;
+    }
+    return "";
+}
+
+void AuthMiddleware::setUnauthorized(httplib::Response& res, const std::string& message) const {
+    res.status = 401;
+    json body;
+    body["error"] = message;
+    res.set_content(body.dump(), "application/json");
+}
+
+void AuthMiddleware::setForbidden(httplib::Response& res, const std::string& message) const {
+    res.status = 403;
+    json body;
+    body["error"] = message;
+    res.set_content(body.dump(), "application/json");
+}
+
+} // namespace smartbotic::microbit::auth

+ 40 - 0
lib/auth/src/bcrypt_utils.cpp

@@ -0,0 +1,40 @@
+#include "smartbotic/microbit/auth/bcrypt_utils.hpp"
+
+#include <openssl/rand.h>
+#include <BCrypt.hpp>
+
+#include <iomanip>
+#include <sstream>
+#include <stdexcept>
+#include <vector>
+
+namespace smartbotic::microbit::auth {
+
+std::string BCryptUtils::hashPassword(const std::string& password, int cost) {
+    if (cost < 4 || cost > 31) cost = DEFAULT_COST;
+    return BCrypt::generateHash(password, cost);
+}
+
+bool BCryptUtils::verifyPassword(const std::string& password, const std::string& hash) {
+    if (password.empty() || hash.empty()) return false;
+    return BCrypt::validatePassword(password, hash);
+}
+
+std::string BCryptUtils::randomHex(size_t numBytes) {
+    std::vector<unsigned char> bytes(numBytes);
+    if (RAND_bytes(bytes.data(), static_cast<int>(numBytes)) != 1) {
+        throw std::runtime_error("Failed to generate random bytes");
+    }
+    std::ostringstream ss;
+    ss << std::hex << std::setfill('0');
+    for (unsigned char byte : bytes) {
+        ss << std::setw(2) << static_cast<int>(byte);
+    }
+    return ss.str();
+}
+
+std::string BCryptUtils::generateId(const std::string& prefix) {
+    return prefix + randomHex(12);
+}
+
+} // namespace smartbotic::microbit::auth

+ 217 - 0
lib/auth/src/jwt_utils.cpp

@@ -0,0 +1,217 @@
+#include "smartbotic/microbit/auth/jwt_utils.hpp"
+#include "smartbotic/microbit/common/utils.hpp"
+
+#include <openssl/hmac.h>
+#include <openssl/evp.h>
+#include <openssl/buffer.h>
+
+#include <sstream>
+#include <cstring>
+
+namespace smartbotic::microbit::auth {
+
+json JWTUtils::JWTPayload::toJson() const {
+    json j;
+    j["sub"] = sub;
+    if (!email.empty()) j["email"] = email;
+    if (!role.empty()) j["role"] = role;
+    j["type"] = type;
+    if (!sessionId.empty()) j["session_id"] = sessionId;
+    j["iat"] = iat;
+    j["exp"] = exp;
+    if (!jti.empty()) j["jti"] = jti;
+    return j;
+}
+
+JWTUtils::JWTPayload JWTUtils::JWTPayload::fromJson(const json& j) {
+    JWTPayload payload;
+    if (j.contains("sub")) payload.sub = j["sub"].get<std::string>();
+    if (j.contains("email")) payload.email = j["email"].get<std::string>();
+    if (j.contains("role")) payload.role = j["role"].get<std::string>();
+    if (j.contains("type")) payload.type = j["type"].get<std::string>();
+    if (j.contains("session_id")) payload.sessionId = j["session_id"].get<std::string>();
+    if (j.contains("iat")) payload.iat = j["iat"].get<uint64_t>();
+    if (j.contains("exp")) payload.exp = j["exp"].get<uint64_t>();
+    if (j.contains("jti")) payload.jti = j["jti"].get<std::string>();
+    return payload;
+}
+
+std::string JWTUtils::createToken(const JWTPayload& payload, const std::string& secret) {
+    json header;
+    header["alg"] = "HS256";
+    header["typ"] = "JWT";
+
+    std::string headerB64 = base64UrlEncode(header.dump());
+    std::string payloadB64 = base64UrlEncode(payload.toJson().dump());
+    std::string signatureInput = headerB64 + "." + payloadB64;
+    std::string signature = hmacSha256(signatureInput, secret);
+    std::string signatureB64 = base64UrlEncode(
+        reinterpret_cast<const unsigned char*>(signature.data()), signature.size());
+
+    return signatureInput + "." + signatureB64;
+}
+
+std::string JWTUtils::createAccessToken(
+    const std::string& userId,
+    const std::string& email,
+    const std::string& role,
+    const std::string& secret,
+    uint32_t lifetimeSec
+) {
+    JWTPayload payload;
+    payload.sub = userId;
+    payload.email = email;
+    payload.role = role;
+    payload.type = TOKEN_TYPE_ACCESS;
+    payload.iat = currentTimestamp();
+    payload.exp = payload.iat + lifetimeSec;
+    payload.jti = generateJti();
+    return createToken(payload, secret);
+}
+
+std::string JWTUtils::createRefreshToken(
+    const std::string& userId,
+    const std::string& sessionId,
+    const std::string& secret,
+    uint32_t lifetimeSec
+) {
+    JWTPayload payload;
+    payload.sub = userId;
+    payload.sessionId = sessionId;
+    payload.type = TOKEN_TYPE_REFRESH;
+    payload.iat = currentTimestamp();
+    payload.exp = payload.iat + lifetimeSec;
+    payload.jti = generateJti();
+    return createToken(payload, secret);
+}
+
+std::optional<JWTUtils::JWTPayload> JWTUtils::validateToken(
+    const std::string& token, const std::string& secret
+) {
+    std::vector<std::string> parts;
+    std::istringstream iss(token);
+    std::string part;
+    while (std::getline(iss, part, '.')) {
+        parts.push_back(part);
+    }
+
+    if (parts.size() != 3) return std::nullopt;
+
+    std::string signatureInput = parts[0] + "." + parts[1];
+    std::string expectedSignature = hmacSha256(signatureInput, secret);
+    std::string expectedSignatureB64 = base64UrlEncode(
+        reinterpret_cast<const unsigned char*>(expectedSignature.data()),
+        expectedSignature.size());
+
+    if (parts[2] != expectedSignatureB64) return std::nullopt;
+
+    std::string payloadJson = base64UrlDecode(parts[1]);
+    if (payloadJson.empty()) return std::nullopt;
+
+    try {
+        json j = json::parse(payloadJson);
+        JWTPayload payload = JWTPayload::fromJson(j);
+        if (isExpired(payload)) return std::nullopt;
+        return payload;
+    } catch (...) {
+        return std::nullopt;
+    }
+}
+
+std::optional<JWTUtils::JWTPayload> JWTUtils::decodeToken(const std::string& token) {
+    std::vector<std::string> parts;
+    std::istringstream iss(token);
+    std::string part;
+    while (std::getline(iss, part, '.')) {
+        parts.push_back(part);
+    }
+    if (parts.size() != 3) return std::nullopt;
+
+    std::string payloadJson = base64UrlDecode(parts[1]);
+    if (payloadJson.empty()) return std::nullopt;
+
+    try {
+        json j = json::parse(payloadJson);
+        return JWTPayload::fromJson(j);
+    } catch (...) {
+        return std::nullopt;
+    }
+}
+
+bool JWTUtils::isExpired(const JWTPayload& payload) {
+    return payload.exp > 0 && currentTimestamp() >= payload.exp;
+}
+
+uint64_t JWTUtils::currentTimestamp() {
+    auto now = std::chrono::system_clock::now();
+    return std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
+}
+
+std::string JWTUtils::base64UrlEncode(const std::string& input) {
+    return base64UrlEncode(
+        reinterpret_cast<const unsigned char*>(input.data()), input.size());
+}
+
+std::string JWTUtils::base64UrlEncode(const unsigned char* data, size_t len) {
+    BIO* b64 = BIO_new(BIO_f_base64());
+    BIO* mem = BIO_new(BIO_s_mem());
+    b64 = BIO_push(b64, mem);
+    BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL);
+    BIO_write(b64, data, static_cast<int>(len));
+    BIO_flush(b64);
+
+    BUF_MEM* bufferPtr;
+    BIO_get_mem_ptr(b64, &bufferPtr);
+    std::string result(bufferPtr->data, bufferPtr->length);
+    BIO_free_all(b64);
+
+    for (char& c : result) {
+        if (c == '+') c = '-';
+        else if (c == '/') c = '_';
+    }
+    while (!result.empty() && result.back() == '=') {
+        result.pop_back();
+    }
+    return result;
+}
+
+std::string JWTUtils::base64UrlDecode(const std::string& input) {
+    std::string base64 = input;
+    for (char& c : base64) {
+        if (c == '-') c = '+';
+        else if (c == '_') c = '/';
+    }
+    while (base64.size() % 4 != 0) {
+        base64 += '=';
+    }
+
+    BIO* b64 = BIO_new(BIO_f_base64());
+    BIO* mem = BIO_new_mem_buf(base64.data(), static_cast<int>(base64.size()));
+    mem = BIO_push(b64, mem);
+    BIO_set_flags(mem, BIO_FLAGS_BASE64_NO_NL);
+
+    std::vector<char> buffer(base64.size());
+    int decodedLen = BIO_read(mem, buffer.data(), static_cast<int>(buffer.size()));
+    BIO_free_all(mem);
+
+    if (decodedLen < 0) return "";
+    return std::string(buffer.data(), decodedLen);
+}
+
+std::string JWTUtils::hmacSha256(const std::string& data, const std::string& secret) {
+    unsigned char result[EVP_MAX_MD_SIZE];
+    unsigned int resultLen = 0;
+
+    HMAC(EVP_sha256(),
+         secret.data(), static_cast<int>(secret.size()),
+         reinterpret_cast<const unsigned char*>(data.data()), data.size(),
+         result, &resultLen);
+
+    return std::string(reinterpret_cast<char*>(result), resultLen);
+}
+
+std::string JWTUtils::generateJti() {
+    return "jwt_" + utils::randomHex(16);
+}
+
+} // namespace smartbotic::microbit::auth

+ 18 - 0
lib/callerai/CMakeLists.txt

@@ -0,0 +1,18 @@
+add_library(microbit_callerai STATIC
+    src/callerai_client.cpp
+)
+
+target_include_directories(microbit_callerai PUBLIC
+    ${CMAKE_CURRENT_SOURCE_DIR}/include
+)
+
+target_link_libraries(microbit_callerai PUBLIC
+    microbit_common
+    httplib
+    OpenSSL::SSL
+    OpenSSL::Crypto
+)
+
+target_compile_definitions(microbit_callerai PUBLIC CPPHTTPLIB_OPENSSL_SUPPORT)
+
+add_library(smartbotic::microbit::callerai ALIAS microbit_callerai)

+ 52 - 0
lib/callerai/include/smartbotic/microbit/callerai/callerai_client.hpp

@@ -0,0 +1,52 @@
+#pragma once
+
+#include <nlohmann/json.hpp>
+#include <httplib.h>
+
+#include <memory>
+#include <string>
+
+namespace smartbotic::microbit::callerai {
+
+using json = nlohmann::json;
+
+struct CallerAIConfig {
+    std::string apiUrl;
+    std::string apiKey;
+    uint32_t timeoutSec = 30;
+};
+
+class CallerAIClient {
+public:
+    explicit CallerAIClient(const CallerAIConfig& config);
+
+    // Phone Numbers
+    json listPhoneNumbers();
+    json getPhoneNumber(const std::string& id);
+    json assignAssistantToPhone(const std::string& phoneId, const std::string& assistantId);
+
+    // Assistants
+    json createAssistant(const json& config);
+    json getAssistant(const std::string& id);
+    json updateAssistant(const std::string& id, const json& config);
+    bool deleteAssistant(const std::string& id);
+
+    // Voices / Providers
+    json listProviderConfigs(const std::string& type = "");
+    json listVoices(const std::string& providerConfigId);
+
+    // Health check
+    bool testConnection();
+
+private:
+    CallerAIConfig config_;
+    std::unique_ptr<httplib::Client> client_;
+
+    json doGet(const std::string& path);
+    json doPost(const std::string& path, const json& body);
+    json doPut(const std::string& path, const json& body);
+    bool doDelete(const std::string& path);
+    void setHeaders(httplib::Headers& headers);
+};
+
+} // namespace smartbotic::microbit::callerai

+ 119 - 0
lib/callerai/src/callerai_client.cpp

@@ -0,0 +1,119 @@
+#include "smartbotic/microbit/callerai/callerai_client.hpp"
+
+#include <spdlog/spdlog.h>
+#include <stdexcept>
+
+namespace smartbotic::microbit::callerai {
+
+CallerAIClient::CallerAIClient(const CallerAIConfig& config) : config_(config) {
+    client_ = std::make_unique<httplib::Client>(config_.apiUrl);
+    client_->set_read_timeout(config_.timeoutSec, 0);
+    client_->set_write_timeout(config_.timeoutSec, 0);
+    client_->set_connection_timeout(10, 0);
+}
+
+void CallerAIClient::setHeaders(httplib::Headers& headers) {
+    headers.emplace("X-API-Key", config_.apiKey);
+    headers.emplace("Content-Type", "application/json");
+}
+
+json CallerAIClient::doGet(const std::string& path) {
+    httplib::Headers headers;
+    setHeaders(headers);
+
+    auto res = client_->Get(path, headers);
+    if (!res) {
+        throw std::runtime_error("CallerAI request failed: " + path);
+    }
+    if (res->status >= 400) {
+        throw std::runtime_error("CallerAI error " + std::to_string(res->status) + ": " + res->body);
+    }
+    return json::parse(res->body);
+}
+
+json CallerAIClient::doPost(const std::string& path, const json& body) {
+    httplib::Headers headers;
+    setHeaders(headers);
+
+    auto res = client_->Post(path, headers, body.dump(), "application/json");
+    if (!res) {
+        throw std::runtime_error("CallerAI request failed: " + path);
+    }
+    if (res->status >= 400) {
+        throw std::runtime_error("CallerAI error " + std::to_string(res->status) + ": " + res->body);
+    }
+    return res->body.empty() ? json{} : json::parse(res->body);
+}
+
+json CallerAIClient::doPut(const std::string& path, const json& body) {
+    httplib::Headers headers;
+    setHeaders(headers);
+
+    auto res = client_->Put(path, headers, body.dump(), "application/json");
+    if (!res) {
+        throw std::runtime_error("CallerAI request failed: " + path);
+    }
+    if (res->status >= 400) {
+        throw std::runtime_error("CallerAI error " + std::to_string(res->status) + ": " + res->body);
+    }
+    return res->body.empty() ? json{} : json::parse(res->body);
+}
+
+bool CallerAIClient::doDelete(const std::string& path) {
+    httplib::Headers headers;
+    setHeaders(headers);
+
+    auto res = client_->Delete(path, headers);
+    if (!res) return false;
+    return res->status < 400;
+}
+
+json CallerAIClient::listPhoneNumbers() {
+    return doGet("/api/v1/phone-numbers");
+}
+
+json CallerAIClient::getPhoneNumber(const std::string& id) {
+    return doGet("/api/v1/phone-numbers/" + id);
+}
+
+json CallerAIClient::assignAssistantToPhone(const std::string& phoneId, const std::string& assistantId) {
+    return doPut("/api/v1/phone-numbers/" + phoneId + "/assistant",
+                 json{{"assistant_id", assistantId}});
+}
+
+json CallerAIClient::createAssistant(const json& config) {
+    return doPost("/api/v1/assistants", config);
+}
+
+json CallerAIClient::getAssistant(const std::string& id) {
+    return doGet("/api/v1/assistants/" + id);
+}
+
+json CallerAIClient::updateAssistant(const std::string& id, const json& config) {
+    return doPut("/api/v1/assistants/" + id, config);
+}
+
+bool CallerAIClient::deleteAssistant(const std::string& id) {
+    return doDelete("/api/v1/assistants/" + id);
+}
+
+json CallerAIClient::listProviderConfigs(const std::string& type) {
+    std::string path = "/api/v1/provider-configs";
+    if (!type.empty()) path += "?type=" + type;
+    return doGet(path);
+}
+
+json CallerAIClient::listVoices(const std::string& providerConfigId) {
+    return doGet("/api/v1/provider-configs/" + providerConfigId + "/voices");
+}
+
+bool CallerAIClient::testConnection() {
+    try {
+        doGet("/api/v1/health");
+        return true;
+    } catch (...) {
+        return false;
+    }
+}
+
+} // namespace smartbotic::microbit::callerai

+ 17 - 0
lib/common/CMakeLists.txt

@@ -0,0 +1,17 @@
+add_library(microbit_common STATIC
+    src/config_loader.cpp
+    src/utils.cpp
+    src/logging.cpp
+)
+
+target_include_directories(microbit_common PUBLIC
+    ${CMAKE_CURRENT_SOURCE_DIR}/include
+)
+
+target_link_libraries(microbit_common PUBLIC
+    nlohmann_json::nlohmann_json
+    spdlog::spdlog
+    OpenSSL::Crypto
+)
+
+add_library(smartbotic::microbit::common ALIAS microbit_common)

+ 62 - 0
lib/common/include/smartbotic/microbit/common/config_loader.hpp

@@ -0,0 +1,62 @@
+#pragma once
+
+#include <nlohmann/json.hpp>
+#include <filesystem>
+#include <optional>
+#include <string>
+#include <string_view>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+class ConfigLoader {
+public:
+    static json loadFromFile(const std::filesystem::path& path);
+    static json loadFromString(std::string_view content);
+    static void expandEnvVars(json& config);
+
+    template<typename T>
+    static T get(const json& config, const std::string& key, const T& defaultValue) {
+        try {
+            auto parts = splitKey(key);
+            const json* current = &config;
+            for (const auto& part : parts) {
+                if (!current->is_object() || !current->contains(part)) {
+                    return defaultValue;
+                }
+                current = &(*current)[part];
+            }
+            return current->get<T>();
+        } catch (...) {
+            return defaultValue;
+        }
+    }
+
+    template<typename T>
+    static std::optional<T> getOptional(const json& config, const std::string& key) {
+        try {
+            auto parts = splitKey(key);
+            const json* current = &config;
+            for (const auto& part : parts) {
+                if (!current->is_object() || !current->contains(part)) {
+                    return std::nullopt;
+                }
+                current = &(*current)[part];
+            }
+            return current->get<T>();
+        } catch (...) {
+            return std::nullopt;
+        }
+    }
+
+    static bool has(const json& config, const std::string& key);
+    static void merge(json& target, const json& source);
+
+private:
+    static std::vector<std::string> splitKey(const std::string& key);
+    static void expandEnvVarsRecursive(json& node);
+};
+
+} // namespace smartbotic::microbit

+ 10 - 0
lib/common/include/smartbotic/microbit/common/logging.hpp

@@ -0,0 +1,10 @@
+#pragma once
+
+#include <spdlog/spdlog.h>
+#include <string>
+
+namespace smartbotic::microbit::logging {
+
+void init(const std::string& serviceName, spdlog::level::level_enum level = spdlog::level::info);
+
+} // namespace smartbotic::microbit::logging

+ 28 - 0
lib/common/include/smartbotic/microbit/common/utils.hpp

@@ -0,0 +1,28 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <string_view>
+#include <vector>
+
+namespace smartbotic::microbit::utils {
+
+// String utilities
+[[nodiscard]] std::vector<std::string> split(std::string_view str, char delimiter);
+[[nodiscard]] std::string trim(std::string_view str);
+[[nodiscard]] std::string toLower(std::string_view str);
+[[nodiscard]] bool startsWith(std::string_view str, std::string_view prefix);
+
+// Environment variable expansion: ${VAR_NAME} or ${VAR_NAME:default}
+[[nodiscard]] std::string expandEnvVars(std::string_view str);
+
+// ID generation
+[[nodiscard]] std::string randomHex(size_t numBytes);
+[[nodiscard]] std::string generateId(const std::string& prefix);
+
+// Time utilities
+[[nodiscard]] uint64_t nowUnixSeconds();
+[[nodiscard]] uint64_t nowUnixMillis();
+[[nodiscard]] std::string formatTimestamp(uint64_t unixSeconds);
+
+} // namespace smartbotic::microbit::utils

+ 84 - 0
lib/common/src/config_loader.cpp

@@ -0,0 +1,84 @@
+#include "smartbotic/microbit/common/config_loader.hpp"
+#include "smartbotic/microbit/common/utils.hpp"
+
+#include <spdlog/spdlog.h>
+#include <fstream>
+#include <sstream>
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+json ConfigLoader::loadFromFile(const std::filesystem::path& path) {
+    if (!std::filesystem::exists(path)) {
+        throw std::runtime_error("Configuration file not found: " + path.string());
+    }
+
+    std::ifstream file(path);
+    if (!file.is_open()) {
+        throw std::runtime_error("Failed to open configuration file: " + path.string());
+    }
+
+    try {
+        std::stringstream buffer;
+        buffer << file.rdbuf();
+        return loadFromString(buffer.str());
+    } catch (const json::exception& e) {
+        throw std::runtime_error("Failed to parse configuration file: " + std::string(e.what()));
+    }
+}
+
+json ConfigLoader::loadFromString(std::string_view content) {
+    json config = json::parse(content);
+    expandEnvVars(config);
+    return config;
+}
+
+void ConfigLoader::expandEnvVars(json& config) {
+    expandEnvVarsRecursive(config);
+}
+
+void ConfigLoader::expandEnvVarsRecursive(json& node) {
+    if (node.is_string()) {
+        std::string value = node.get<std::string>();
+        if (value.find("${") != std::string::npos) {
+            node = utils::expandEnvVars(value);
+        }
+    } else if (node.is_object()) {
+        for (auto& [key, value] : node.items()) {
+            expandEnvVarsRecursive(value);
+        }
+    } else if (node.is_array()) {
+        for (auto& item : node) {
+            expandEnvVarsRecursive(item);
+        }
+    }
+}
+
+bool ConfigLoader::has(const json& config, const std::string& key) {
+    auto parts = splitKey(key);
+    const json* current = &config;
+    for (const auto& part : parts) {
+        if (!current->is_object() || !current->contains(part)) {
+            return false;
+        }
+        current = &(*current)[part];
+    }
+    return true;
+}
+
+void ConfigLoader::merge(json& target, const json& source) {
+    if (!source.is_object()) return;
+    for (const auto& [key, value] : source.items()) {
+        if (target.contains(key) && target[key].is_object() && value.is_object()) {
+            merge(target[key], value);
+        } else {
+            target[key] = value;
+        }
+    }
+}
+
+std::vector<std::string> ConfigLoader::splitKey(const std::string& key) {
+    return utils::split(key, '.');
+}
+
+} // namespace smartbotic::microbit

+ 14 - 0
lib/common/src/logging.cpp

@@ -0,0 +1,14 @@
+#include "smartbotic/microbit/common/logging.hpp"
+
+#include <spdlog/sinks/stdout_color_sinks.h>
+
+namespace smartbotic::microbit::logging {
+
+void init(const std::string& serviceName, spdlog::level::level_enum level) {
+    auto console = spdlog::stdout_color_mt(serviceName);
+    spdlog::set_default_logger(console);
+    spdlog::set_level(level);
+    spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%n] [%^%l%$] %v");
+}
+
+} // namespace smartbotic::microbit::logging

+ 130 - 0
lib/common/src/utils.cpp

@@ -0,0 +1,130 @@
+#include "smartbotic/microbit/common/utils.hpp"
+
+#include <openssl/rand.h>
+
+#include <algorithm>
+#include <chrono>
+#include <cstdlib>
+#include <ctime>
+#include <iomanip>
+#include <sstream>
+#include <stdexcept>
+
+namespace smartbotic::microbit::utils {
+
+std::vector<std::string> split(std::string_view str, char delimiter) {
+    std::vector<std::string> result;
+    size_t start = 0;
+    size_t end;
+    while ((end = str.find(delimiter, start)) != std::string_view::npos) {
+        if (end > start) {
+            result.emplace_back(str.substr(start, end - start));
+        }
+        start = end + 1;
+    }
+    if (start < str.size()) {
+        result.emplace_back(str.substr(start));
+    }
+    return result;
+}
+
+std::string trim(std::string_view str) {
+    auto start = str.find_first_not_of(" \t\r\n");
+    if (start == std::string_view::npos) return "";
+    auto end = str.find_last_not_of(" \t\r\n");
+    return std::string(str.substr(start, end - start + 1));
+}
+
+std::string toLower(std::string_view str) {
+    std::string result(str);
+    std::transform(result.begin(), result.end(), result.begin(), ::tolower);
+    return result;
+}
+
+bool startsWith(std::string_view str, std::string_view prefix) {
+    return str.size() >= prefix.size() && str.compare(0, prefix.size(), prefix) == 0;
+}
+
+std::string expandEnvVars(std::string_view str) {
+    std::string result;
+    result.reserve(str.size());
+
+    size_t i = 0;
+    while (i < str.size()) {
+        if (i + 1 < str.size() && str[i] == '$' && str[i + 1] == '{') {
+            // Find closing brace
+            size_t end = str.find('}', i + 2);
+            if (end == std::string_view::npos) {
+                result += str.substr(i);
+                break;
+            }
+
+            std::string_view varExpr = str.substr(i + 2, end - i - 2);
+
+            // Check for default value: ${VAR:default}
+            size_t colonPos = varExpr.find(':');
+            std::string varName;
+            std::string defaultValue;
+
+            if (colonPos != std::string_view::npos) {
+                varName = std::string(varExpr.substr(0, colonPos));
+                defaultValue = std::string(varExpr.substr(colonPos + 1));
+            } else {
+                varName = std::string(varExpr);
+            }
+
+            const char* envValue = std::getenv(varName.c_str());
+            if (envValue && envValue[0] != '\0') {
+                result += envValue;
+            } else {
+                result += defaultValue;
+            }
+
+            i = end + 1;
+        } else {
+            result += str[i];
+            ++i;
+        }
+    }
+
+    return result;
+}
+
+std::string randomHex(size_t numBytes) {
+    std::vector<unsigned char> bytes(numBytes);
+    if (RAND_bytes(bytes.data(), static_cast<int>(numBytes)) != 1) {
+        throw std::runtime_error("Failed to generate random bytes");
+    }
+
+    std::ostringstream ss;
+    ss << std::hex << std::setfill('0');
+    for (unsigned char byte : bytes) {
+        ss << std::setw(2) << static_cast<int>(byte);
+    }
+    return ss.str();
+}
+
+std::string generateId(const std::string& prefix) {
+    return prefix + randomHex(12);
+}
+
+uint64_t nowUnixSeconds() {
+    auto now = std::chrono::system_clock::now();
+    return std::chrono::duration_cast<std::chrono::seconds>(now.time_since_epoch()).count();
+}
+
+uint64_t nowUnixMillis() {
+    auto now = std::chrono::system_clock::now();
+    return std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()).count();
+}
+
+std::string formatTimestamp(uint64_t unixSeconds) {
+    std::time_t t = static_cast<std::time_t>(unixSeconds);
+    std::tm tm{};
+    gmtime_r(&t, &tm);
+    std::ostringstream ss;
+    ss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ");
+    return ss.str();
+}
+
+} // namespace smartbotic::microbit::utils

+ 14 - 0
lib/smtp/CMakeLists.txt

@@ -0,0 +1,14 @@
+add_library(microbit_smtp STATIC
+    src/smtp_client.cpp
+)
+
+target_include_directories(microbit_smtp PUBLIC
+    ${CMAKE_CURRENT_SOURCE_DIR}/include
+)
+
+target_link_libraries(microbit_smtp PUBLIC
+    microbit_common
+    CURL::libcurl
+)
+
+add_library(smartbotic::microbit::smtp ALIAS microbit_smtp)

+ 33 - 0
lib/smtp/include/smartbotic/microbit/smtp/smtp_client.hpp

@@ -0,0 +1,33 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+
+namespace smartbotic::microbit::smtp {
+
+struct SmtpConfig {
+    std::string host;
+    uint16_t port = 587;
+    std::string username;
+    std::string password;
+    std::string fromAddress;
+    std::string fromName = "Smartbotic";
+    bool useTls = true;
+};
+
+class SmtpClient {
+public:
+    explicit SmtpClient(const SmtpConfig& config);
+
+    bool sendEmail(const std::string& to, const std::string& subject,
+                   const std::string& htmlBody, const std::string& textBody = "");
+
+    bool testConnection();
+
+private:
+    SmtpConfig config_;
+    std::string buildMessage(const std::string& to, const std::string& subject,
+                             const std::string& htmlBody, const std::string& textBody) const;
+};
+
+} // namespace smartbotic::microbit::smtp

+ 141 - 0
lib/smtp/src/smtp_client.cpp

@@ -0,0 +1,141 @@
+#include "smartbotic/microbit/smtp/smtp_client.hpp"
+
+#include <spdlog/spdlog.h>
+#include <curl/curl.h>
+
+#include <cstring>
+#include <sstream>
+
+namespace smartbotic::microbit::smtp {
+
+struct UploadContext {
+    const char* data;
+    size_t remaining;
+};
+
+static size_t payloadSource(char* ptr, size_t size, size_t nmemb, void* userp) {
+    auto* ctx = static_cast<UploadContext*>(userp);
+    size_t maxBytes = size * nmemb;
+    if (ctx->remaining == 0) return 0;
+
+    size_t toCopy = std::min(maxBytes, ctx->remaining);
+    std::memcpy(ptr, ctx->data, toCopy);
+    ctx->data += toCopy;
+    ctx->remaining -= toCopy;
+    return toCopy;
+}
+
+SmtpClient::SmtpClient(const SmtpConfig& config) : config_(config) {}
+
+bool SmtpClient::sendEmail(const std::string& to, const std::string& subject,
+                           const std::string& htmlBody, const std::string& textBody) {
+    if (config_.host.empty()) {
+        spdlog::warn("SMTP not configured, skipping email to {}", to);
+        return false;
+    }
+
+    CURL* curl = curl_easy_init();
+    if (!curl) {
+        spdlog::error("Failed to initialize CURL for SMTP");
+        return false;
+    }
+
+    std::string message = buildMessage(to, subject, htmlBody, textBody);
+    UploadContext ctx{message.c_str(), message.size()};
+
+    std::string url = (config_.useTls ? "smtps://" : "smtp://") +
+                      config_.host + ":" + std::to_string(config_.port);
+
+    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
+    if (config_.useTls) {
+        curl_easy_setopt(curl, CURLOPT_USE_SSL, static_cast<long>(CURLUSESSL_ALL));
+    }
+
+    if (!config_.username.empty()) {
+        curl_easy_setopt(curl, CURLOPT_USERNAME, config_.username.c_str());
+        curl_easy_setopt(curl, CURLOPT_PASSWORD, config_.password.c_str());
+    }
+
+    curl_easy_setopt(curl, CURLOPT_MAIL_FROM, config_.fromAddress.c_str());
+
+    struct curl_slist* recipients = nullptr;
+    recipients = curl_slist_append(recipients, to.c_str());
+    curl_easy_setopt(curl, CURLOPT_MAIL_RCPT, recipients);
+
+    curl_easy_setopt(curl, CURLOPT_READFUNCTION, payloadSource);
+    curl_easy_setopt(curl, CURLOPT_READDATA, &ctx);
+    curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L);
+    curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
+
+    CURLcode res = curl_easy_perform(curl);
+
+    curl_slist_free_all(recipients);
+    curl_easy_cleanup(curl);
+
+    if (res != CURLE_OK) {
+        spdlog::error("SMTP send failed: {}", curl_easy_strerror(res));
+        return false;
+    }
+
+    spdlog::info("Email sent to {}: {}", to, subject);
+    return true;
+}
+
+bool SmtpClient::testConnection() {
+    if (config_.host.empty()) return false;
+
+    CURL* curl = curl_easy_init();
+    if (!curl) return false;
+
+    std::string url = (config_.useTls ? "smtps://" : "smtp://") +
+                      config_.host + ":" + std::to_string(config_.port);
+
+    curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
+    if (config_.useTls) {
+        curl_easy_setopt(curl, CURLOPT_USE_SSL, static_cast<long>(CURLUSESSL_ALL));
+    }
+    if (!config_.username.empty()) {
+        curl_easy_setopt(curl, CURLOPT_USERNAME, config_.username.c_str());
+        curl_easy_setopt(curl, CURLOPT_PASSWORD, config_.password.c_str());
+    }
+    curl_easy_setopt(curl, CURLOPT_CONNECT_ONLY, 1L);
+    curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
+
+    CURLcode res = curl_easy_perform(curl);
+    curl_easy_cleanup(curl);
+
+    return res == CURLE_OK;
+}
+
+std::string SmtpClient::buildMessage(const std::string& to, const std::string& subject,
+                                     const std::string& htmlBody, const std::string& textBody) const {
+    std::ostringstream ss;
+    ss << "From: " << config_.fromName << " <" << config_.fromAddress << ">\r\n";
+    ss << "To: " << to << "\r\n";
+    ss << "Subject: " << subject << "\r\n";
+    ss << "MIME-Version: 1.0\r\n";
+
+    if (!htmlBody.empty()) {
+        std::string boundary = "----=_Part_" + std::to_string(std::hash<std::string>{}(subject));
+        ss << "Content-Type: multipart/alternative; boundary=\"" << boundary << "\"\r\n";
+        ss << "\r\n";
+
+        if (!textBody.empty()) {
+            ss << "--" << boundary << "\r\n";
+            ss << "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
+            ss << textBody << "\r\n";
+        }
+
+        ss << "--" << boundary << "\r\n";
+        ss << "Content-Type: text/html; charset=UTF-8\r\n\r\n";
+        ss << htmlBody << "\r\n";
+        ss << "--" << boundary << "--\r\n";
+    } else {
+        ss << "Content-Type: text/plain; charset=UTF-8\r\n\r\n";
+        ss << textBody << "\r\n";
+    }
+
+    return ss.str();
+}
+
+} // namespace smartbotic::microbit::smtp

+ 49 - 0
src/CMakeLists.txt

@@ -0,0 +1,49 @@
+add_executable(smartbotic-microbit
+    main.cpp
+    app.cpp
+    # Stores
+    stores/user_store.cpp
+    stores/workspace_store.cpp
+    stores/invitation_store.cpp
+    stores/assistant_store.cpp
+    stores/calendar_store.cpp
+    stores/settings_store.cpp
+    # Services
+    services/auth_service.cpp
+    services/workspace_service.cpp
+    services/invitation_service.cpp
+    services/assistant_service.cpp
+    services/calendar_service.cpp
+    # Routes
+    routes/auth_routes.cpp
+    routes/user_routes.cpp
+    routes/workspace_routes.cpp
+    routes/invitation_routes.cpp
+    routes/assistant_routes.cpp
+    routes/calendar_routes.cpp
+    routes/settings_routes.cpp
+)
+
+target_include_directories(smartbotic-microbit PRIVATE
+    ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+find_package(absl REQUIRED)
+
+target_link_libraries(smartbotic-microbit PRIVATE
+    smartbotic-db-client
+    microbit_common
+    microbit_auth
+    microbit_smtp
+    microbit_callerai
+    httplib
+    nlohmann_json::nlohmann_json
+    spdlog::spdlog
+    OpenSSL::SSL
+    OpenSSL::Crypto
+    CURL::libcurl
+    bcrypt
+    absl::log_internal_check_op
+)
+
+target_compile_definitions(smartbotic-microbit PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)

+ 196 - 0
src/app.cpp

@@ -0,0 +1,196 @@
+#include "app.hpp"
+
+#include "stores/user_store.hpp"
+#include "stores/workspace_store.hpp"
+#include "stores/invitation_store.hpp"
+#include "stores/assistant_store.hpp"
+#include "stores/calendar_store.hpp"
+#include "stores/settings_store.hpp"
+#include "services/auth_service.hpp"
+#include "services/workspace_service.hpp"
+#include "services/invitation_service.hpp"
+#include "services/assistant_service.hpp"
+#include "services/calendar_service.hpp"
+#include "routes/auth_routes.hpp"
+#include "routes/user_routes.hpp"
+#include "routes/workspace_routes.hpp"
+#include "routes/invitation_routes.hpp"
+#include "routes/assistant_routes.hpp"
+#include "routes/calendar_routes.hpp"
+#include "routes/settings_routes.hpp"
+
+#include <smartbotic/microbit/common/config_loader.hpp>
+
+#include <spdlog/spdlog.h>
+#include <filesystem>
+
+namespace smartbotic::microbit {
+
+App::App(const json& config) : config_(config) {
+    bindAddress_ = ConfigLoader::get<std::string>(config_, "http.bind_address", "0.0.0.0");
+    httpPort_ = ConfigLoader::get<uint16_t>(config_, "http.port", 8090);
+    staticFilesPath_ = ConfigLoader::get<std::string>(config_, "http.static_files.path", "webui/dist");
+}
+
+App::~App() {
+    stop();
+}
+
+void App::connectDatabase() {
+    smartbotic::database::Client::Config dbConfig;
+    dbConfig.address = ConfigLoader::get<std::string>(config_, "database.rpc_address", "localhost:9004");
+    dbConfig.timeoutMs = ConfigLoader::get<uint32_t>(config_, "database.timeout_ms", 5000);
+    dbConfig.maxRetries = ConfigLoader::get<uint32_t>(config_, "database.max_retries", 3);
+
+    db_ = std::make_unique<smartbotic::database::Client>(dbConfig);
+    if (!db_->connect()) {
+        throw std::runtime_error("Failed to connect to database at " + dbConfig.address);
+    }
+    spdlog::info("Connected to database at {}", dbConfig.address);
+}
+
+void App::initializeComponents() {
+    // Auth middleware
+    auth::AuthConfig authConfig;
+    authConfig.enabled = ConfigLoader::get<bool>(config_, "auth.enabled", true);
+    authConfig.jwtSecret = ConfigLoader::get<std::string>(config_, "auth.jwt_secret", "");
+    authConfig.accessTokenLifetimeSec = ConfigLoader::get<uint32_t>(config_, "auth.access_token_lifetime_sec", 900);
+    authConfig.refreshTokenLifetimeSec = ConfigLoader::get<uint32_t>(config_, "auth.refresh_token_lifetime_sec", 604800);
+    authConfig.minPasswordLength = ConfigLoader::get<uint32_t>(config_, "auth.password_policy.min_length", 8);
+    authConfig.requireNumber = ConfigLoader::get<bool>(config_, "auth.password_policy.require_number", true);
+    authConfig.requireSpecial = ConfigLoader::get<bool>(config_, "auth.password_policy.require_special", false);
+
+    if (authConfig.jwtSecret.empty() || authConfig.jwtSecret == "change-me-in-production") {
+        spdlog::warn("JWT secret not configured or using default. Set JWT_SECRET environment variable.");
+    }
+
+    authMiddleware_ = std::make_unique<auth::AuthMiddleware>(authConfig);
+
+    // SMTP client
+    smtp::SmtpConfig smtpConfig;
+    smtpConfig.host = ConfigLoader::get<std::string>(config_, "smtp.host", "");
+    smtpConfig.port = ConfigLoader::get<uint16_t>(config_, "smtp.port", 587);
+    smtpConfig.username = ConfigLoader::get<std::string>(config_, "smtp.username", "");
+    smtpConfig.password = ConfigLoader::get<std::string>(config_, "smtp.password", "");
+    smtpConfig.fromAddress = ConfigLoader::get<std::string>(config_, "smtp.from_address", "noreply@smartbotics.ai");
+    smtpConfig.fromName = ConfigLoader::get<std::string>(config_, "smtp.from_name", "Smartbotic");
+    smtpConfig.useTls = ConfigLoader::get<bool>(config_, "smtp.use_tls", true);
+    smtpClient_ = std::make_unique<smtp::SmtpClient>(smtpConfig);
+
+    // CallerAI client
+    callerai::CallerAIConfig calleraiConfig;
+    calleraiConfig.apiUrl = ConfigLoader::get<std::string>(config_, "callerai.api_url", "http://localhost:8080");
+    calleraiConfig.apiKey = ConfigLoader::get<std::string>(config_, "callerai.api_key", "");
+    calleraiConfig.timeoutSec = ConfigLoader::get<uint32_t>(config_, "callerai.timeout_sec", 30);
+    calleraiClient_ = std::make_unique<callerai::CallerAIClient>(calleraiConfig);
+
+    // Stores
+    userStore_ = std::make_unique<UserStore>(db_.get());
+    workspaceStore_ = std::make_unique<WorkspaceStore>(db_.get());
+    invitationStore_ = std::make_unique<InvitationStore>(db_.get());
+    assistantStore_ = std::make_unique<AssistantStore>(db_.get());
+    calendarStore_ = std::make_unique<CalendarStore>(db_.get());
+    settingsStore_ = std::make_unique<SettingsStore>(db_.get());
+
+    // Services
+    authService_ = std::make_unique<AuthService>(userStore_.get(), authMiddleware_.get());
+    workspaceService_ = std::make_unique<WorkspaceService>(workspaceStore_.get());
+    invitationService_ = std::make_unique<InvitationService>(
+        invitationStore_.get(), workspaceStore_.get(), userStore_.get(), smtpClient_.get());
+    assistantService_ = std::make_unique<AssistantService>(assistantStore_.get(), calleraiClient_.get());
+    calendarService_ = std::make_unique<CalendarService>(calendarStore_.get());
+}
+
+void App::setupCors(httplib::Server& svr) {
+    bool corsEnabled = ConfigLoader::get<bool>(config_, "cors.enabled", true);
+    if (!corsEnabled) return;
+
+    svr.Options(R"(/api/.*)", [](const httplib::Request&, httplib::Response& res) {
+        res.set_header("Access-Control-Allow-Origin", "*");
+        res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
+        res.set_header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With");
+        res.set_header("Access-Control-Max-Age", "86400");
+        res.status = 204;
+    });
+
+    svr.set_post_routing_handler([](const httplib::Request&, httplib::Response& res) {
+        res.set_header("Access-Control-Allow-Origin", "*");
+    });
+}
+
+void App::setupRoutes(httplib::Server& svr) {
+    // Health check
+    svr.Get("/api/v1/health", [](const httplib::Request&, httplib::Response& res) {
+        res.set_content(R"({"status":"ok"})", "application/json");
+    });
+
+    // Auth middleware
+    svr.set_pre_routing_handler([this](const httplib::Request& req, httplib::Response& res) {
+        if (req.method == "OPTIONS") {
+            return httplib::Server::HandlerResponse::Unhandled;
+        }
+        if (req.path.find("/api/") != 0) {
+            return httplib::Server::HandlerResponse::Unhandled;
+        }
+        if (authMiddleware_->isPublicPath(req.path)) {
+            return httplib::Server::HandlerResponse::Unhandled;
+        }
+        if (!authMiddleware_->authenticate(req, res)) {
+            return httplib::Server::HandlerResponse::Handled;
+        }
+        return httplib::Server::HandlerResponse::Unhandled;
+    });
+
+    // Register all routes
+    setupAuthRoutes(svr, *this);
+    setupUserRoutes(svr, *this);
+    setupWorkspaceRoutes(svr, *this);
+    setupInvitationRoutes(svr, *this);
+    setupAssistantRoutes(svr, *this);
+    setupCalendarRoutes(svr, *this);
+    setupSettingsRoutes(svr, *this);
+}
+
+void App::start() {
+    if (running_) return;
+
+    spdlog::info("Starting Smartbotic MicroBit v{}", "0.1.0");
+
+    connectDatabase();
+    initializeComponents();
+
+    httpServer_ = std::make_unique<httplib::Server>();
+
+    setupCors(*httpServer_);
+    setupRoutes(*httpServer_);
+
+    // Serve static files
+    bool serveStatic = ConfigLoader::get<bool>(config_, "http.static_files.enabled", true);
+    if (serveStatic && std::filesystem::exists(staticFilesPath_)) {
+        httpServer_->set_mount_point("/", staticFilesPath_);
+        spdlog::info("Serving static files from {}", staticFilesPath_);
+    }
+
+    running_ = true;
+
+    httpThread_ = std::thread([this]() {
+        spdlog::info("HTTP server listening on {}:{}", bindAddress_, httpPort_);
+        httpServer_->listen(bindAddress_, httpPort_);
+    });
+}
+
+void App::stop() {
+    if (!running_) return;
+    running_ = false;
+
+    if (httpServer_) {
+        httpServer_->stop();
+    }
+    if (httpThread_.joinable()) {
+        httpThread_.join();
+    }
+
+    spdlog::info("Smartbotic MicroBit stopped");
+}
+
+} // namespace smartbotic::microbit

+ 104 - 0
src/app.hpp

@@ -0,0 +1,104 @@
+#pragma once
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+#include <smartbotic/microbit/smtp/smtp_client.hpp>
+#include <smartbotic/microbit/callerai/callerai_client.hpp>
+#include <smartbotic/database/client.hpp>
+
+#include <httplib.h>
+#include <nlohmann/json.hpp>
+
+#include <atomic>
+#include <memory>
+#include <string>
+#include <thread>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+// Forward declarations
+class UserStore;
+class WorkspaceStore;
+class InvitationStore;
+class AssistantStore;
+class CalendarStore;
+class SettingsStore;
+class AuthService;
+class WorkspaceService;
+class InvitationService;
+class AssistantService;
+class CalendarService;
+
+class App {
+public:
+    explicit App(const json& config);
+    ~App();
+
+    void start();
+    void stop();
+    [[nodiscard]] bool isRunning() const { return running_; }
+
+    // Accessors for route handlers
+    auth::AuthMiddleware* authMiddleware() { return authMiddleware_.get(); }
+    UserStore* userStore() { return userStore_.get(); }
+    WorkspaceStore* workspaceStore() { return workspaceStore_.get(); }
+    InvitationStore* invitationStore() { return invitationStore_.get(); }
+    AssistantStore* assistantStore() { return assistantStore_.get(); }
+    CalendarStore* calendarStore() { return calendarStore_.get(); }
+    SettingsStore* settingsStore() { return settingsStore_.get(); }
+
+    AuthService* authService() { return authService_.get(); }
+    WorkspaceService* workspaceService() { return workspaceService_.get(); }
+    InvitationService* invitationService() { return invitationService_.get(); }
+    AssistantService* assistantService() { return assistantService_.get(); }
+    CalendarService* calendarService() { return calendarService_.get(); }
+
+    smtp::SmtpClient* smtpClient() { return smtpClient_.get(); }
+    callerai::CallerAIClient* calleraiClient() { return calleraiClient_.get(); }
+
+    const json& config() const { return config_; }
+
+private:
+    void setupRoutes(httplib::Server& svr);
+    void setupCors(httplib::Server& svr);
+    void connectDatabase();
+    void initializeComponents();
+
+    json config_;
+    std::atomic<bool> running_{false};
+
+    // HTTP
+    std::string bindAddress_ = "0.0.0.0";
+    uint16_t httpPort_ = 8090;
+    std::string staticFilesPath_;
+    std::unique_ptr<httplib::Server> httpServer_;
+    std::thread httpThread_;
+
+    // Database
+    std::unique_ptr<smartbotic::database::Client> db_;
+
+    // Auth
+    std::unique_ptr<auth::AuthMiddleware> authMiddleware_;
+
+    // External clients
+    std::unique_ptr<smtp::SmtpClient> smtpClient_;
+    std::unique_ptr<callerai::CallerAIClient> calleraiClient_;
+
+    // Stores
+    std::unique_ptr<UserStore> userStore_;
+    std::unique_ptr<WorkspaceStore> workspaceStore_;
+    std::unique_ptr<InvitationStore> invitationStore_;
+    std::unique_ptr<AssistantStore> assistantStore_;
+    std::unique_ptr<CalendarStore> calendarStore_;
+    std::unique_ptr<SettingsStore> settingsStore_;
+
+    // Services
+    std::unique_ptr<AuthService> authService_;
+    std::unique_ptr<WorkspaceService> workspaceService_;
+    std::unique_ptr<InvitationService> invitationService_;
+    std::unique_ptr<AssistantService> assistantService_;
+    std::unique_ptr<CalendarService> calendarService_;
+};
+
+} // namespace smartbotic::microbit

+ 57 - 0
src/main.cpp

@@ -0,0 +1,57 @@
+#include "app.hpp"
+#include <smartbotic/microbit/common/config_loader.hpp>
+#include <smartbotic/microbit/common/logging.hpp>
+
+#include <spdlog/spdlog.h>
+
+#include <csignal>
+#include <filesystem>
+#include <iostream>
+
+namespace {
+    std::atomic<bool> g_running{true};
+    void signalHandler(int) { g_running = false; }
+}
+
+int main(int argc, char* argv[]) {
+    std::filesystem::path configPath = "config/microbit.json";
+
+    for (int i = 1; i < argc; ++i) {
+        std::string arg = argv[i];
+        if ((arg == "--config" || arg == "-c") && i + 1 < argc) {
+            configPath = argv[++i];
+        } else if (arg == "--help" || arg == "-h") {
+            std::cout << "Usage: smartbotic-microbit [options]\n"
+                      << "  --config, -c <path>  Configuration file path (default: config/microbit.json)\n"
+                      << "  --help, -h           Show this help\n";
+            return 0;
+        }
+    }
+
+    try {
+        auto config = smartbotic::microbit::ConfigLoader::loadFromFile(configPath);
+
+        auto logLevel = spdlog::level::from_str(
+            smartbotic::microbit::ConfigLoader::get<std::string>(config, "log_level", "info"));
+        smartbotic::microbit::logging::init("microbit", logLevel);
+
+        std::signal(SIGINT, signalHandler);
+        std::signal(SIGTERM, signalHandler);
+        std::signal(SIGPIPE, SIG_IGN);
+
+        smartbotic::microbit::App app(config);
+        app.start();
+
+        while (g_running && app.isRunning()) {
+            std::this_thread::sleep_for(std::chrono::milliseconds(100));
+        }
+
+        app.stop();
+
+    } catch (const std::exception& e) {
+        spdlog::error("Fatal error: {}", e.what());
+        return 1;
+    }
+
+    return 0;
+}

+ 456 - 0
src/routes/assistant_routes.cpp

@@ -0,0 +1,456 @@
+#include "assistant_routes.hpp"
+#include "../app.hpp"
+#include "../stores/assistant_store.hpp"
+#include "../stores/workspace_store.hpp"
+#include "../services/workspace_service.hpp"
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+#include <smartbotic/microbit/auth/bcrypt_utils.hpp>
+#include <smartbotic/microbit/callerai/callerai_client.hpp>
+#include <smartbotic/microbit/common/logging.hpp>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+void setupAssistantRoutes(httplib::Server& svr, App& app) {
+
+    // GET /api/v1/workspaces/:wsId/assistants -- list assistants (member+)
+    svr.Get(R"(/api/v1/workspaces/([^/]+)/assistants)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            // Check membership
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            auto assistants = app.assistantStore()->listByWorkspace(wsId);
+            json arr = json::array();
+            for (const auto& a : assistants) {
+                arr.push_back(a.toJson());
+            }
+
+            res.status = 200;
+            res.set_content(json{{"assistants", arr}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List assistants error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/workspaces/:wsId/assistants -- create assistant (admin+)
+    svr.Post(R"(/api/v1/workspaces/([^/]+)/assistants)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+
+            std::string phoneNumberId = body.value("phone_number_id", "");
+            if (phoneNumberId.empty()) {
+                throw std::invalid_argument("phone_number_id is required");
+            }
+
+            json customConfig = body.value("custom_config", json::object());
+            std::string voiceId = body.value("voice_id", "");
+            std::string voiceProviderConfigId = body.value("voice_provider_config_id", "");
+
+            // Create assistant via CallerAI
+            json calleraiConfig = customConfig;
+            if (!voiceId.empty()) {
+                calleraiConfig["voice_id"] = voiceId;
+            }
+            if (!voiceProviderConfigId.empty()) {
+                calleraiConfig["voice_provider_config_id"] = voiceProviderConfigId;
+            }
+
+            json calleraiAssistant = app.calleraiClient()->createAssistant(calleraiConfig);
+            std::string calleraiAssistantId = calleraiAssistant.value("id", "");
+
+            // Assign to phone number
+            if (!calleraiAssistantId.empty() && !phoneNumberId.empty()) {
+                try {
+                    app.calleraiClient()->assignAssistantToPhone(phoneNumberId, calleraiAssistantId);
+                } catch (const std::exception& e) {
+                    spdlog::warn("Failed to assign assistant to phone: {}", e.what());
+                }
+            }
+
+            // Create local assistant record
+            Assistant assistant;
+            assistant.id = auth::BCryptUtils::generateId("ast");
+            assistant.workspaceId = wsId;
+            assistant.calleraiAssistantId = calleraiAssistantId;
+            assistant.calleraiPhoneNumberId = phoneNumberId;
+            assistant.name = calleraiAssistant.value("name", "Assistant");
+            assistant.customConfig = customConfig;
+            assistant.voiceId = voiceId;
+            assistant.voiceProviderConfigId = voiceProviderConfigId;
+            assistant.status = "active";
+
+            app.assistantStore()->createAssistant(assistant);
+
+            auto created = app.assistantStore()->getAssistant(assistant.id);
+            res.status = 201;
+            res.set_content(json{{"assistant", created->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Create assistant error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/workspaces/:wsId/assistants/:id -- get assistant (member+)
+    svr.Get(R"(/api/v1/workspaces/([^/]+)/assistants/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string assistantId = req.matches[2];
+
+            // Check membership
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            auto assistant = app.assistantStore()->getAssistant(assistantId);
+            if (!assistant || assistant->workspaceId != wsId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Assistant not found"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"assistant", assistant->toJson()}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Get assistant error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // PUT /api/v1/workspaces/:wsId/assistants/:id -- update assistant config (admin+)
+    svr.Put(R"(/api/v1/workspaces/([^/]+)/assistants/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string assistantId = req.matches[2];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto assistant = app.assistantStore()->getAssistant(assistantId);
+            if (!assistant || assistant->workspaceId != wsId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Assistant not found"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+            json customConfig = body.value("custom_config", json::object());
+
+            // Update on CallerAI
+            if (!assistant->calleraiAssistantId.empty()) {
+                try {
+                    app.calleraiClient()->updateAssistant(assistant->calleraiAssistantId, customConfig);
+                } catch (const std::exception& e) {
+                    spdlog::warn("Failed to update CallerAI assistant: {}", e.what());
+                }
+            }
+
+            // Update local record
+            json updateFields = json::object();
+            updateFields["custom_config"] = customConfig;
+            app.assistantStore()->updateAssistant(assistantId, updateFields);
+
+            auto updated = app.assistantStore()->getAssistant(assistantId);
+            res.status = 200;
+            res.set_content(json{{"assistant", updated->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Update assistant error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // PUT /api/v1/workspaces/:wsId/assistants/:id/voice -- change voice (admin+)
+    svr.Put(R"(/api/v1/workspaces/([^/]+)/assistants/([^/]+)/voice)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string assistantId = req.matches[2];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto assistant = app.assistantStore()->getAssistant(assistantId);
+            if (!assistant || assistant->workspaceId != wsId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Assistant not found"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+            std::string voiceId = body.value("voice_id", "");
+            std::string voiceProviderConfigId = body.value("voice_provider_config_id", "");
+
+            if (voiceId.empty()) {
+                throw std::invalid_argument("voice_id is required");
+            }
+
+            // Update voice on CallerAI
+            if (!assistant->calleraiAssistantId.empty()) {
+                json voiceUpdate = {
+                    {"voice_id", voiceId},
+                    {"voice_provider_config_id", voiceProviderConfigId}
+                };
+                try {
+                    app.calleraiClient()->updateAssistant(assistant->calleraiAssistantId, voiceUpdate);
+                } catch (const std::exception& e) {
+                    spdlog::warn("Failed to update CallerAI assistant voice: {}", e.what());
+                }
+            }
+
+            // Update local record
+            json updateFields = {
+                {"voice_id", voiceId},
+                {"voice_provider_config_id", voiceProviderConfigId}
+            };
+            app.assistantStore()->updateAssistant(assistantId, updateFields);
+
+            auto updated = app.assistantStore()->getAssistant(assistantId);
+            res.status = 200;
+            res.set_content(json{{"assistant", updated->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Change assistant voice error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // DELETE /api/v1/workspaces/:wsId/assistants/:id -- delete assistant (admin+)
+    svr.Delete(R"(/api/v1/workspaces/([^/]+)/assistants/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string assistantId = req.matches[2];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto assistant = app.assistantStore()->getAssistant(assistantId);
+            if (!assistant || assistant->workspaceId != wsId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Assistant not found"}}.dump(), "application/json");
+                return;
+            }
+
+            // Delete on CallerAI
+            if (!assistant->calleraiAssistantId.empty()) {
+                try {
+                    app.calleraiClient()->deleteAssistant(assistant->calleraiAssistantId);
+                } catch (const std::exception& e) {
+                    spdlog::warn("Failed to delete CallerAI assistant: {}", e.what());
+                }
+            }
+
+            bool deleted = app.assistantStore()->deleteAssistant(assistantId);
+            if (!deleted) {
+                res.status = 500;
+                res.set_content(json{{"error", "Failed to delete assistant"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"message", "Assistant deleted"}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Delete assistant error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/callerai/phone-numbers -- list available phone numbers
+    svr.Get("/api/v1/callerai/phone-numbers", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            json phoneNumbers = app.calleraiClient()->listPhoneNumbers();
+
+            res.status = 200;
+            res.set_content(json{{"phone_numbers", phoneNumbers}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List phone numbers error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/callerai/voices/:providerConfigId -- list voices for provider
+    svr.Get(R"(/api/v1/callerai/voices/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string providerConfigId = req.matches[1];
+
+            json voices = app.calleraiClient()->listVoices(providerConfigId);
+
+            res.status = 200;
+            res.set_content(json{{"voices", voices}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List voices error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/callerai/provider-configs -- list TTS provider configs
+    svr.Get("/api/v1/callerai/provider-configs", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            json configs = app.calleraiClient()->listProviderConfigs("tts");
+
+            res.status = 200;
+            res.set_content(json{{"provider_configs", configs}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List provider configs error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+}
+
+} // namespace smartbotic::microbit

+ 11 - 0
src/routes/assistant_routes.hpp

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <httplib.h>
+
+namespace smartbotic::microbit {
+
+class App;
+
+void setupAssistantRoutes(httplib::Server& svr, App& app);
+
+} // namespace smartbotic::microbit

+ 327 - 0
src/routes/auth_routes.cpp

@@ -0,0 +1,327 @@
+#include "auth_routes.hpp"
+#include "../app.hpp"
+#include "../stores/user_store.hpp"
+#include "../services/invitation_service.hpp"
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+#include <smartbotic/microbit/auth/bcrypt_utils.hpp>
+#include <smartbotic/microbit/common/logging.hpp>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+void setupAuthRoutes(httplib::Server& svr, App& app) {
+
+    // POST /api/v1/auth/register
+    svr.Post("/api/v1/auth/register", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto body = json::parse(req.body);
+
+            std::string email = body.value("email", "");
+            std::string password = body.value("password", "");
+            std::string displayName = body.value("display_name", "");
+            std::string inviteToken = body.value("invite_token", "");
+
+            if (email.empty() || password.empty()) {
+                throw std::invalid_argument("Email and password are required");
+            }
+
+            // Validate password
+            std::string passwordError = app.authMiddleware()->validatePassword(password);
+            if (!passwordError.empty()) {
+                throw std::invalid_argument(passwordError);
+            }
+
+            // Check if user already exists
+            auto existing = app.userStore()->getUserByEmail(email);
+            if (existing) {
+                throw std::invalid_argument("Email already registered");
+            }
+
+            // Create user
+            User user;
+            user.id = auth::BCryptUtils::generateId("usr");
+            user.email = email;
+            user.passwordHash = auth::BCryptUtils::hashPassword(password);
+            user.displayName = displayName.empty() ? email : displayName;
+            user.status = "active";
+
+            app.userStore()->createUser(user);
+
+            // Process invitation if invite_token provided
+            if (!inviteToken.empty()) {
+                try {
+                    app.invitationService()->processRegistrationInvites(user.id, user.email);
+                } catch (const std::exception& e) {
+                    spdlog::warn("Failed to process registration invites: {}", e.what());
+                }
+            }
+
+            // Create session
+            Session session;
+            session.id = auth::BCryptUtils::generateId("ses");
+            session.userId = user.id;
+            app.userStore()->createSession(session);
+
+            // Generate tokens
+            std::string accessToken = app.authMiddleware()->createAccessToken(
+                user.id, user.email, "user");
+            std::string refreshToken = app.authMiddleware()->createRefreshToken(
+                user.id, session.id);
+
+            json response = {
+                {"user", user.toJson()},
+                {"access_token", accessToken},
+                {"refresh_token", refreshToken},
+                {"token_type", "Bearer"},
+                {"expires_in", app.authMiddleware()->config().accessTokenLifetimeSec}
+            };
+
+            // Remove password hash from user response
+            response["user"].erase("password_hash");
+
+            res.status = 201;
+            res.set_content(response.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Register error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/auth/login
+    svr.Post("/api/v1/auth/login", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto body = json::parse(req.body);
+
+            std::string email = body.value("email", "");
+            std::string password = body.value("password", "");
+
+            if (email.empty() || password.empty()) {
+                throw std::invalid_argument("Email and password are required");
+            }
+
+            auto user = app.userStore()->getUserByEmail(email);
+            if (!user) {
+                throw std::invalid_argument("Invalid email or password");
+            }
+
+            if (!auth::BCryptUtils::verifyPassword(password, user->passwordHash)) {
+                throw std::invalid_argument("Invalid email or password");
+            }
+
+            if (user->status != "active") {
+                throw std::invalid_argument("Account is not active");
+            }
+
+            // Delete existing sessions for user
+            app.userStore()->deleteUserSessions(user->id);
+
+            // Create new session
+            Session session;
+            session.id = auth::BCryptUtils::generateId("ses");
+            session.userId = user->id;
+            app.userStore()->createSession(session);
+
+            // Generate tokens
+            std::string accessToken = app.authMiddleware()->createAccessToken(
+                user->id, user->email, "user");
+            std::string refreshToken = app.authMiddleware()->createRefreshToken(
+                user->id, session.id);
+
+            json response = {
+                {"user", user->toJson()},
+                {"access_token", accessToken},
+                {"refresh_token", refreshToken},
+                {"token_type", "Bearer"},
+                {"expires_in", app.authMiddleware()->config().accessTokenLifetimeSec}
+            };
+
+            response["user"].erase("password_hash");
+
+            res.status = 200;
+            res.set_content(response.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Login error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/auth/refresh
+    svr.Post("/api/v1/auth/refresh", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto body = json::parse(req.body);
+
+            std::string refreshToken = body.value("refresh_token", "");
+            if (refreshToken.empty()) {
+                throw std::invalid_argument("Refresh token is required");
+            }
+
+            // Validate the refresh token
+            auto payload = app.authMiddleware()->validateToken(refreshToken);
+            if (!payload) {
+                throw std::invalid_argument("Invalid or expired refresh token");
+            }
+
+            // Look up the user
+            auto user = app.userStore()->getUser(payload->userId);
+            if (!user) {
+                throw std::invalid_argument("User not found");
+            }
+
+            // Generate a new access token
+            std::string accessToken = app.authMiddleware()->createAccessToken(
+                user->id, user->email, "user");
+
+            json response = {
+                {"access_token", accessToken},
+                {"token_type", "Bearer"},
+                {"expires_in", app.authMiddleware()->config().accessTokenLifetimeSec}
+            };
+
+            res.status = 200;
+            res.set_content(response.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Refresh error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/auth/logout (requires auth)
+    svr.Post("/api/v1/auth/logout", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            // Delete all sessions for this user
+            app.userStore()->deleteUserSessions(authCtx->userId);
+
+            res.status = 200;
+            res.set_content(json{{"message", "Logged out successfully"}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Logout error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/auth/me (requires auth)
+    svr.Get("/api/v1/auth/me", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            auto user = app.userStore()->getUser(authCtx->userId);
+            if (!user) {
+                res.status = 404;
+                res.set_content(json{{"error", "User not found"}}.dump(), "application/json");
+                return;
+            }
+
+            json userJson = user->toJson();
+            userJson.erase("password_hash");
+
+            res.status = 200;
+            res.set_content(json{{"user", userJson}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Get me error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/auth/change-password (requires auth)
+    svr.Post("/api/v1/auth/change-password", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+            std::string currentPassword = body.value("current_password", "");
+            std::string newPassword = body.value("new_password", "");
+
+            if (currentPassword.empty() || newPassword.empty()) {
+                throw std::invalid_argument("Current password and new password are required");
+            }
+
+            // Validate new password
+            std::string passwordError = app.authMiddleware()->validatePassword(newPassword);
+            if (!passwordError.empty()) {
+                throw std::invalid_argument(passwordError);
+            }
+
+            auto user = app.userStore()->getUser(authCtx->userId);
+            if (!user) {
+                res.status = 404;
+                res.set_content(json{{"error", "User not found"}}.dump(), "application/json");
+                return;
+            }
+
+            // Verify current password
+            if (!auth::BCryptUtils::verifyPassword(currentPassword, user->passwordHash)) {
+                throw std::invalid_argument("Current password is incorrect");
+            }
+
+            // Hash and update new password
+            std::string newHash = auth::BCryptUtils::hashPassword(newPassword);
+            app.userStore()->updateUser(authCtx->userId, json{{"password_hash", newHash}});
+
+            res.status = 200;
+            res.set_content(json{{"message", "Password changed successfully"}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Change password error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+}
+
+} // namespace smartbotic::microbit

+ 11 - 0
src/routes/auth_routes.hpp

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <httplib.h>
+
+namespace smartbotic::microbit {
+
+class App;
+
+void setupAuthRoutes(httplib::Server& svr, App& app);
+
+} // namespace smartbotic::microbit

+ 755 - 0
src/routes/calendar_routes.cpp

@@ -0,0 +1,755 @@
+#include "calendar_routes.hpp"
+#include "../app.hpp"
+#include "../stores/calendar_store.hpp"
+#include "../stores/workspace_store.hpp"
+#include "../services/workspace_service.hpp"
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+#include <smartbotic/microbit/auth/bcrypt_utils.hpp>
+#include <smartbotic/microbit/common/logging.hpp>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include <ctime>
+#include <string>
+#include <unordered_set>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+// Helper: look up the workspace that owns a calendar, then check membership
+static bool checkCalendarWorkspaceRole(
+    App& app,
+    const auth::AuthContext& authCtx,
+    const std::string& calId,
+    std::string& outWsId,
+    std::string& outRole,
+    httplib::Response& res)
+{
+    auto cal = app.calendarStore()->getCalendar(calId);
+    if (!cal) {
+        res.status = 404;
+        res.set_content(json{{"error", "Calendar not found"}}.dump(), "application/json");
+        return false;
+    }
+    outWsId = cal->workspaceId;
+    outRole = app.workspaceService()->getUserRole(authCtx.userId, outWsId);
+    if (outRole.empty()) {
+        res.status = 403;
+        res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+        return false;
+    }
+    return true;
+}
+
+void setupCalendarRoutes(httplib::Server& svr, App& app) {
+
+    // =========================================================================
+    // Calendar CRUD (workspace-scoped)
+    // =========================================================================
+
+    // GET /api/v1/workspaces/:wsId/calendars -- list calendars
+    svr.Get(R"(/api/v1/workspaces/([^/]+)/calendars)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            auto calendars = app.calendarStore()->listByWorkspace(wsId);
+            json arr = json::array();
+            for (const auto& c : calendars) {
+                arr.push_back(c.toJson());
+            }
+
+            res.status = 200;
+            res.set_content(json{{"calendars", arr}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List calendars error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/workspaces/:wsId/calendars -- create calendar (admin+)
+    svr.Post(R"(/api/v1/workspaces/([^/]+)/calendars)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+            std::string name = body.value("name", "");
+            std::string timezone = body.value("timezone", "");
+
+            if (name.empty()) {
+                throw std::invalid_argument("Name is required");
+            }
+
+            Calendar cal;
+            cal.id = auth::BCryptUtils::generateId("cal");
+            cal.workspaceId = wsId;
+            cal.name = name;
+            cal.timezone = timezone;
+
+            app.calendarStore()->createCalendar(cal);
+
+            auto created = app.calendarStore()->getCalendar(cal.id);
+            res.status = 201;
+            res.set_content(json{{"calendar", created->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Create calendar error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/workspaces/:wsId/calendars/:id -- get calendar
+    svr.Get(R"(/api/v1/workspaces/([^/]+)/calendars/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string calId = req.matches[2];
+
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            auto cal = app.calendarStore()->getCalendar(calId);
+            if (!cal || cal->workspaceId != wsId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Calendar not found"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"calendar", cal->toJson()}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Get calendar error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // PUT /api/v1/workspaces/:wsId/calendars/:id -- update calendar (admin+)
+    svr.Put(R"(/api/v1/workspaces/([^/]+)/calendars/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string calId = req.matches[2];
+
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto cal = app.calendarStore()->getCalendar(calId);
+            if (!cal || cal->workspaceId != wsId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Calendar not found"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+            json updateFields = json::object();
+            if (body.contains("name") && body["name"].is_string()) {
+                updateFields["name"] = body["name"];
+            }
+            if (body.contains("timezone") && body["timezone"].is_string()) {
+                updateFields["timezone"] = body["timezone"];
+            }
+
+            if (updateFields.empty()) {
+                throw std::invalid_argument("No valid fields to update");
+            }
+
+            app.calendarStore()->updateCalendar(calId, updateFields);
+            auto updated = app.calendarStore()->getCalendar(calId);
+
+            res.status = 200;
+            res.set_content(json{{"calendar", updated->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Update calendar error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // DELETE /api/v1/workspaces/:wsId/calendars/:id -- delete calendar (admin+)
+    svr.Delete(R"(/api/v1/workspaces/([^/]+)/calendars/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string calId = req.matches[2];
+
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto cal = app.calendarStore()->getCalendar(calId);
+            if (!cal || cal->workspaceId != wsId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Calendar not found"}}.dump(), "application/json");
+                return;
+            }
+
+            bool deleted = app.calendarStore()->deleteCalendar(calId);
+            if (!deleted) {
+                res.status = 500;
+                res.set_content(json{{"error", "Failed to delete calendar"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"message", "Calendar deleted"}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Delete calendar error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // =========================================================================
+    // TimeSlot CRUD (calendar-scoped)
+    // =========================================================================
+
+    // GET /api/v1/calendars/:calId/time-slots -- list time slots
+    svr.Get(R"(/api/v1/calendars/([^/]+)/time-slots)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string calId = req.matches[1];
+            std::string wsId, role;
+            if (!checkCalendarWorkspaceRole(app, *authCtx, calId, wsId, role, res)) {
+                return;
+            }
+
+            auto slots = app.calendarStore()->listSlotsByCalendar(calId);
+            json arr = json::array();
+            for (const auto& s : slots) {
+                arr.push_back(s.toJson());
+            }
+
+            res.status = 200;
+            res.set_content(json{{"time_slots", arr}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List time slots error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/calendars/:calId/time-slots -- create time slot (admin+)
+    svr.Post(R"(/api/v1/calendars/([^/]+)/time-slots)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string calId = req.matches[1];
+            std::string wsId, role;
+            if (!checkCalendarWorkspaceRole(app, *authCtx, calId, wsId, role, res)) {
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+
+            int dayOfWeek = body.value("day_of_week", -1);
+            std::string startTime = body.value("start_time", "");
+            std::string endTime = body.value("end_time", "");
+
+            if (dayOfWeek < 0 || dayOfWeek > 6) {
+                throw std::invalid_argument("day_of_week must be 0-6");
+            }
+            if (startTime.empty() || endTime.empty()) {
+                throw std::invalid_argument("start_time and end_time are required");
+            }
+
+            TimeSlot slot;
+            slot.id = auth::BCryptUtils::generateId("ts");
+            slot.calendarId = calId;
+            slot.workspaceId = wsId;
+            slot.dayOfWeek = dayOfWeek;
+            slot.startTime = startTime;
+            slot.endTime = endTime;
+            slot.isActive = true;
+
+            app.calendarStore()->createTimeSlot(slot);
+
+            auto created = app.calendarStore()->getTimeSlot(slot.id);
+            res.status = 201;
+            res.set_content(json{{"time_slot", created->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Create time slot error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // PUT /api/v1/calendars/:calId/time-slots/:id -- update time slot (admin+)
+    svr.Put(R"(/api/v1/calendars/([^/]+)/time-slots/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string calId = req.matches[1];
+            std::string slotId = req.matches[2];
+
+            std::string wsId, role;
+            if (!checkCalendarWorkspaceRole(app, *authCtx, calId, wsId, role, res)) {
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto slot = app.calendarStore()->getTimeSlot(slotId);
+            if (!slot || slot->calendarId != calId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Time slot not found"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+            json updateFields = json::object();
+            if (body.contains("day_of_week") && body["day_of_week"].is_number_integer()) {
+                updateFields["day_of_week"] = body["day_of_week"];
+            }
+            if (body.contains("start_time") && body["start_time"].is_string()) {
+                updateFields["start_time"] = body["start_time"];
+            }
+            if (body.contains("end_time") && body["end_time"].is_string()) {
+                updateFields["end_time"] = body["end_time"];
+            }
+            if (body.contains("is_active") && body["is_active"].is_boolean()) {
+                updateFields["is_active"] = body["is_active"];
+            }
+
+            if (updateFields.empty()) {
+                throw std::invalid_argument("No valid fields to update");
+            }
+
+            app.calendarStore()->updateTimeSlot(slotId, updateFields);
+            auto updated = app.calendarStore()->getTimeSlot(slotId);
+
+            res.status = 200;
+            res.set_content(json{{"time_slot", updated->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Update time slot error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // DELETE /api/v1/calendars/:calId/time-slots/:id -- delete time slot (admin+)
+    svr.Delete(R"(/api/v1/calendars/([^/]+)/time-slots/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string calId = req.matches[1];
+            std::string slotId = req.matches[2];
+
+            std::string wsId, role;
+            if (!checkCalendarWorkspaceRole(app, *authCtx, calId, wsId, role, res)) {
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto slot = app.calendarStore()->getTimeSlot(slotId);
+            if (!slot || slot->calendarId != calId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Time slot not found"}}.dump(), "application/json");
+                return;
+            }
+
+            bool deleted = app.calendarStore()->deleteTimeSlot(slotId);
+            if (!deleted) {
+                res.status = 500;
+                res.set_content(json{{"error", "Failed to delete time slot"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"message", "Time slot deleted"}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Delete time slot error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // =========================================================================
+    // Available slots & Appointments
+    // =========================================================================
+
+    // GET /api/v1/calendars/:calId/available-slots -- ?date=YYYY-MM-DD
+    svr.Get(R"(/api/v1/calendars/([^/]+)/available-slots)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string calId = req.matches[1];
+            std::string wsId, role;
+            if (!checkCalendarWorkspaceRole(app, *authCtx, calId, wsId, role, res)) {
+                return;
+            }
+
+            std::string date;
+            if (req.has_param("date")) {
+                date = req.get_param_value("date");
+            }
+            if (date.empty()) {
+                throw std::invalid_argument("date query parameter is required (YYYY-MM-DD)");
+            }
+
+            // Get all time slots for the calendar
+            auto allSlots = app.calendarStore()->listSlotsByCalendar(calId);
+
+            // Get all appointments for this calendar and date
+            auto appointments = app.calendarStore()->listAppointmentsByCalendarAndDate(calId, date);
+
+            // Build a set of already-booked time slot IDs for the given date
+            std::unordered_set<std::string> bookedSlotIds;
+            for (const auto& apt : appointments) {
+                if (apt.status == "booked") {
+                    bookedSlotIds.insert(apt.timeSlotId);
+                }
+            }
+
+            // Determine day of week from the date string
+            // Simple parsing: YYYY-MM-DD -> use std::tm / mktime
+            int year = 0, month = 0, day = 0;
+            if (date.size() == 10) {
+                year = std::stoi(date.substr(0, 4));
+                month = std::stoi(date.substr(5, 2));
+                day = std::stoi(date.substr(8, 2));
+            }
+
+            int dayOfWeek = -1;
+            if (year > 0 && month > 0 && day > 0) {
+                std::tm tm = {};
+                tm.tm_year = year - 1900;
+                tm.tm_mon = month - 1;
+                tm.tm_mday = day;
+                std::mktime(&tm);
+                dayOfWeek = tm.tm_wday;  // 0 = Sunday
+            }
+
+            json available = json::array();
+            for (const auto& slot : allSlots) {
+                if (!slot.isActive) continue;
+                if (dayOfWeek >= 0 && slot.dayOfWeek != dayOfWeek) continue;
+                if (bookedSlotIds.count(slot.id)) continue;
+                available.push_back(slot.toJson());
+            }
+
+            res.status = 200;
+            res.set_content(json{{"available_slots", available}, {"date", date}}.dump(), "application/json");
+
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Available slots error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/workspaces/:wsId/appointments -- list ?date_from=&date_to=
+    svr.Get(R"(/api/v1/workspaces/([^/]+)/appointments)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            std::string dateFrom, dateTo;
+            if (req.has_param("date_from")) {
+                dateFrom = req.get_param_value("date_from");
+            }
+            if (req.has_param("date_to")) {
+                dateTo = req.get_param_value("date_to");
+            }
+
+            auto appointments = app.calendarStore()->listAppointmentsByWorkspace(wsId, dateFrom, dateTo);
+            json arr = json::array();
+            for (const auto& a : appointments) {
+                arr.push_back(a.toJson());
+            }
+
+            res.status = 200;
+            res.set_content(json{{"appointments", arr}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List appointments error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/calendars/:calId/appointments -- book appointment
+    svr.Post(R"(/api/v1/calendars/([^/]+)/appointments)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string calId = req.matches[1];
+            std::string wsId, role;
+            if (!checkCalendarWorkspaceRole(app, *authCtx, calId, wsId, role, res)) {
+                return;
+            }
+
+            auto body = json::parse(req.body);
+
+            std::string timeSlotId = body.value("time_slot_id", "");
+            std::string date = body.value("date", "");
+            std::string callerName = body.value("caller_name", "");
+            std::string callerPhone = body.value("caller_phone", "");
+            std::string notes = body.value("notes", "");
+
+            if (timeSlotId.empty() || date.empty()) {
+                throw std::invalid_argument("time_slot_id and date are required");
+            }
+
+            // Verify the time slot belongs to this calendar
+            auto slot = app.calendarStore()->getTimeSlot(timeSlotId);
+            if (!slot || slot->calendarId != calId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Time slot not found"}}.dump(), "application/json");
+                return;
+            }
+
+            // Check the slot is not already booked for that date
+            auto existing = app.calendarStore()->listAppointmentsByCalendarAndDate(calId, date);
+            for (const auto& apt : existing) {
+                if (apt.timeSlotId == timeSlotId && apt.status == "booked") {
+                    throw std::invalid_argument("This time slot is already booked for the given date");
+                }
+            }
+
+            Appointment appointment;
+            appointment.id = auth::BCryptUtils::generateId("apt");
+            appointment.calendarId = calId;
+            appointment.workspaceId = wsId;
+            appointment.timeSlotId = timeSlotId;
+            appointment.date = date;
+            appointment.callerName = callerName;
+            appointment.callerPhone = callerPhone;
+            appointment.notes = notes;
+            appointment.status = "booked";
+
+            app.calendarStore()->createAppointment(appointment);
+
+            auto created = app.calendarStore()->getAppointment(appointment.id);
+            res.status = 201;
+            res.set_content(json{{"appointment", created->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Book appointment error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // PUT /api/v1/appointments/:id/cancel -- cancel appointment
+    svr.Put(R"(/api/v1/appointments/([^/]+)/cancel)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string appointmentId = req.matches[1];
+
+            auto appointment = app.calendarStore()->getAppointment(appointmentId);
+            if (!appointment) {
+                res.status = 404;
+                res.set_content(json{{"error", "Appointment not found"}}.dump(), "application/json");
+                return;
+            }
+
+            // Check user has access to the workspace
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, appointment->workspaceId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            if (appointment->status != "booked") {
+                throw std::invalid_argument("Appointment is not in a cancellable state");
+            }
+
+            app.calendarStore()->updateAppointment(appointmentId, json{{"status", "cancelled"}});
+
+            auto updated = app.calendarStore()->getAppointment(appointmentId);
+            res.status = 200;
+            res.set_content(json{{"appointment", updated->toJson()}}.dump(), "application/json");
+
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Cancel appointment error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+}
+
+} // namespace smartbotic::microbit

+ 11 - 0
src/routes/calendar_routes.hpp

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <httplib.h>
+
+namespace smartbotic::microbit {
+
+class App;
+
+void setupCalendarRoutes(httplib::Server& svr, App& app);
+
+} // namespace smartbotic::microbit

+ 301 - 0
src/routes/invitation_routes.cpp

@@ -0,0 +1,301 @@
+#include "invitation_routes.hpp"
+#include "../app.hpp"
+#include "../stores/invitation_store.hpp"
+#include "../stores/workspace_store.hpp"
+#include "../stores/user_store.hpp"
+#include "../services/workspace_service.hpp"
+#include "../services/invitation_service.hpp"
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+#include <smartbotic/microbit/auth/bcrypt_utils.hpp>
+#include <smartbotic/microbit/common/logging.hpp>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+void setupInvitationRoutes(httplib::Server& svr, App& app) {
+
+    // POST /api/v1/workspaces/:id/invitations -- create invitation (admin+)
+    svr.Post(R"(/api/v1/workspaces/([^/]+)/invitations)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+            std::string email = body.value("email", "");
+            std::string inviteRole = body.value("role", "member");
+
+            if (email.empty()) {
+                throw std::invalid_argument("Email is required");
+            }
+
+            // Create invitation via service (handles email sending, etc.)
+            auto invitation = app.invitationService()->inviteUser(
+                wsId, email, inviteRole, authCtx->userId, "");
+
+            res.status = 201;
+            res.set_content(json{{"invitation", invitation.toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Create invitation error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/workspaces/:id/invitations -- list workspace invitations (admin+)
+    svr.Get(R"(/api/v1/workspaces/([^/]+)/invitations)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto invitations = app.invitationStore()->listWorkspaceInvitations(wsId);
+            json arr = json::array();
+            for (const auto& inv : invitations) {
+                arr.push_back(inv.toJson());
+            }
+
+            res.status = 200;
+            res.set_content(json{{"invitations", arr}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List invitations error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // DELETE /api/v1/workspaces/:id/invitations/:invId -- cancel invitation (admin+)
+    svr.Delete(R"(/api/v1/workspaces/([^/]+)/invitations/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string invId = req.matches[2];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            // Verify invitation belongs to this workspace
+            auto invitation = app.invitationStore()->getInvitation(invId);
+            if (!invitation || invitation->workspaceId != wsId) {
+                res.status = 404;
+                res.set_content(json{{"error", "Invitation not found"}}.dump(), "application/json");
+                return;
+            }
+
+            bool deleted = app.invitationStore()->deleteInvitation(invId);
+            if (!deleted) {
+                res.status = 500;
+                res.set_content(json{{"error", "Failed to delete invitation"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"message", "Invitation cancelled"}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Cancel invitation error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/invitations/pending -- list user's pending invitations
+    svr.Get("/api/v1/invitations/pending", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            auto invitations = app.invitationStore()->listPendingByEmail(authCtx->email);
+            json arr = json::array();
+            for (const auto& inv : invitations) {
+                json invJson = inv.toJson();
+                // Enrich with workspace info
+                auto ws = app.workspaceStore()->getWorkspace(inv.workspaceId);
+                if (ws) {
+                    invJson["workspace"] = ws->toJson();
+                }
+                arr.push_back(invJson);
+            }
+
+            res.status = 200;
+            res.set_content(json{{"invitations", arr}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List pending invitations error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/invitations/:token/accept -- accept invitation
+    svr.Post(R"(/api/v1/invitations/([^/]+)/accept)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string token = req.matches[1];
+
+            auto invitation = app.invitationStore()->getInvitationByToken(token);
+            if (!invitation) {
+                res.status = 404;
+                res.set_content(json{{"error", "Invitation not found"}}.dump(), "application/json");
+                return;
+            }
+
+            if (invitation->status != "pending") {
+                throw std::invalid_argument("Invitation is no longer pending");
+            }
+
+            // Verify the invitation is for the authenticated user's email
+            if (invitation->email != authCtx->email) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: invitation is not for this user"}}.dump(), "application/json");
+                return;
+            }
+
+            // Add user as member of the workspace
+            WorkspaceMember member;
+            member.id = auth::BCryptUtils::generateId("wm");
+            member.workspaceId = invitation->workspaceId;
+            member.userId = authCtx->userId;
+            member.role = invitation->role;
+            member.invitedBy = invitation->invitedBy;
+            app.workspaceStore()->addMember(member);
+
+            // Update invitation status
+            app.invitationStore()->updateStatus(invitation->id, "accepted");
+
+            res.status = 200;
+            res.set_content(json{{"message", "Invitation accepted"}}.dump(), "application/json");
+
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Accept invitation error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/invitations/:token/decline -- decline invitation
+    svr.Post(R"(/api/v1/invitations/([^/]+)/decline)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string token = req.matches[1];
+
+            auto invitation = app.invitationStore()->getInvitationByToken(token);
+            if (!invitation) {
+                res.status = 404;
+                res.set_content(json{{"error", "Invitation not found"}}.dump(), "application/json");
+                return;
+            }
+
+            if (invitation->status != "pending") {
+                throw std::invalid_argument("Invitation is no longer pending");
+            }
+
+            // Verify the invitation is for the authenticated user's email
+            if (invitation->email != authCtx->email) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: invitation is not for this user"}}.dump(), "application/json");
+                return;
+            }
+
+            // Update invitation status
+            app.invitationStore()->updateStatus(invitation->id, "declined");
+
+            res.status = 200;
+            res.set_content(json{{"message", "Invitation declined"}}.dump(), "application/json");
+
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Decline invitation error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+}
+
+} // namespace smartbotic::microbit

+ 11 - 0
src/routes/invitation_routes.hpp

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <httplib.h>
+
+namespace smartbotic::microbit {
+
+class App;
+
+void setupInvitationRoutes(httplib::Server& svr, App& app);
+
+} // namespace smartbotic::microbit

+ 215 - 0
src/routes/settings_routes.cpp

@@ -0,0 +1,215 @@
+#include "settings_routes.hpp"
+#include "../app.hpp"
+#include "../stores/settings_store.hpp"
+#include "../stores/workspace_store.hpp"
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+#include <smartbotic/microbit/smtp/smtp_client.hpp>
+#include <smartbotic/microbit/callerai/callerai_client.hpp>
+#include <smartbotic/microbit/common/logging.hpp>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+// Helper: check if user is owner of any workspace
+static bool isOwnerOfAnyWorkspace(App& app, const std::string& userId) {
+    auto workspaces = app.workspaceStore()->listUserWorkspaces(userId);
+    for (const auto& ws : workspaces) {
+        auto member = app.workspaceStore()->getMember(ws.id, userId);
+        if (member && member->role == "owner") {
+            return true;
+        }
+    }
+    return false;
+}
+
+// Helper: mask sensitive fields in settings
+static json maskSensitiveFields(const json& settings) {
+    json masked = settings;
+
+    // List of keys whose values should be masked
+    static const std::vector<std::string> sensitiveKeys = {
+        "smtp_password", "callerai_api_key", "api_key", "secret", "password"
+    };
+
+    for (const auto& key : sensitiveKeys) {
+        if (masked.contains(key) && masked[key].is_string()) {
+            std::string val = masked[key].get<std::string>();
+            if (val.size() > 4) {
+                masked[key] = std::string(val.size() - 4, '*') + val.substr(val.size() - 4);
+            } else if (!val.empty()) {
+                masked[key] = "****";
+            }
+        }
+    }
+
+    return masked;
+}
+
+void setupSettingsRoutes(httplib::Server& svr, App& app) {
+
+    // GET /api/v1/settings -- get global settings (owner of any workspace)
+    svr.Get("/api/v1/settings", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            if (!isOwnerOfAnyWorkspace(app, authCtx->userId)) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires owner role in at least one workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            auto settings = app.settingsStore()->getGlobalSettings();
+            json settingsJson = settings.value_or(json::object());
+
+            // Mask sensitive fields before returning
+            json masked = maskSensitiveFields(settingsJson);
+
+            res.status = 200;
+            res.set_content(json{{"settings", masked}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Get settings error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // PUT /api/v1/settings -- update global settings (owner)
+    svr.Put("/api/v1/settings", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            if (!isOwnerOfAnyWorkspace(app, authCtx->userId)) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires owner role in at least one workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+
+            bool updated = app.settingsStore()->updateGlobalSettings(body);
+            if (!updated) {
+                res.status = 500;
+                res.set_content(json{{"error", "Failed to update settings"}}.dump(), "application/json");
+                return;
+            }
+
+            // Return the updated (masked) settings
+            auto settings = app.settingsStore()->getGlobalSettings();
+            json settingsJson = settings.value_or(json::object());
+            json masked = maskSensitiveFields(settingsJson);
+
+            res.status = 200;
+            res.set_content(json{{"settings", masked}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Update settings error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/settings/test-smtp -- test SMTP connection (owner)
+    svr.Post("/api/v1/settings/test-smtp", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            if (!isOwnerOfAnyWorkspace(app, authCtx->userId)) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires owner role in at least one workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            if (!app.smtpClient()) {
+                throw std::invalid_argument("SMTP client is not configured");
+            }
+
+            bool success = app.smtpClient()->testConnection();
+            if (success) {
+                res.status = 200;
+                res.set_content(json{{"message", "SMTP connection successful"}}.dump(), "application/json");
+            } else {
+                res.status = 500;
+                res.set_content(json{{"error", "SMTP connection failed"}}.dump(), "application/json");
+            }
+
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Test SMTP error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/settings/test-callerai -- test CallerAI connection (owner)
+    svr.Post("/api/v1/settings/test-callerai", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            if (!isOwnerOfAnyWorkspace(app, authCtx->userId)) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires owner role in at least one workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            if (!app.calleraiClient()) {
+                throw std::invalid_argument("CallerAI client is not configured");
+            }
+
+            bool success = app.calleraiClient()->testConnection();
+            if (success) {
+                res.status = 200;
+                res.set_content(json{{"message", "CallerAI connection successful"}}.dump(), "application/json");
+            } else {
+                res.status = 500;
+                res.set_content(json{{"error", "CallerAI connection failed"}}.dump(), "application/json");
+            }
+
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Test CallerAI error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+}
+
+} // namespace smartbotic::microbit

+ 11 - 0
src/routes/settings_routes.hpp

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <httplib.h>
+
+namespace smartbotic::microbit {
+
+class App;
+
+void setupSettingsRoutes(httplib::Server& svr, App& app);
+
+} // namespace smartbotic::microbit

+ 75 - 0
src/routes/user_routes.cpp

@@ -0,0 +1,75 @@
+#include "user_routes.hpp"
+#include "../app.hpp"
+#include "../stores/user_store.hpp"
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+#include <smartbotic/microbit/common/logging.hpp>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+void setupUserRoutes(httplib::Server& svr, App& app) {
+
+    // PUT /api/v1/users/me -- update current user profile
+    svr.Put("/api/v1/users/me", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+
+            json updateFields = json::object();
+            if (body.contains("display_name") && body["display_name"].is_string()) {
+                updateFields["display_name"] = body["display_name"];
+            }
+            if (body.contains("avatar_url") && body["avatar_url"].is_string()) {
+                updateFields["avatar_url"] = body["avatar_url"];
+            }
+
+            if (updateFields.empty()) {
+                throw std::invalid_argument("No valid fields to update");
+            }
+
+            bool updated = app.userStore()->updateUser(authCtx->userId, updateFields);
+            if (!updated) {
+                res.status = 404;
+                res.set_content(json{{"error", "User not found"}}.dump(), "application/json");
+                return;
+            }
+
+            auto user = app.userStore()->getUser(authCtx->userId);
+            if (!user) {
+                res.status = 404;
+                res.set_content(json{{"error", "User not found"}}.dump(), "application/json");
+                return;
+            }
+
+            json userJson = user->toJson();
+            userJson.erase("password_hash");
+
+            res.status = 200;
+            res.set_content(json{{"user", userJson}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Update user error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+}
+
+} // namespace smartbotic::microbit

+ 11 - 0
src/routes/user_routes.hpp

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <httplib.h>
+
+namespace smartbotic::microbit {
+
+class App;
+
+void setupUserRoutes(httplib::Server& svr, App& app);
+
+} // namespace smartbotic::microbit

+ 425 - 0
src/routes/workspace_routes.cpp

@@ -0,0 +1,425 @@
+#include "workspace_routes.hpp"
+#include "../app.hpp"
+#include "../stores/workspace_store.hpp"
+#include "../stores/user_store.hpp"
+#include "../services/workspace_service.hpp"
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+#include <smartbotic/microbit/auth/bcrypt_utils.hpp>
+#include <smartbotic/microbit/common/logging.hpp>
+
+#include <nlohmann/json.hpp>
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+void setupWorkspaceRoutes(httplib::Server& svr, App& app) {
+
+    // GET /api/v1/workspaces -- list user's workspaces
+    svr.Get("/api/v1/workspaces", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            auto workspaces = app.workspaceStore()->listUserWorkspaces(authCtx->userId);
+            json arr = json::array();
+            for (const auto& ws : workspaces) {
+                arr.push_back(ws.toJson());
+            }
+
+            res.status = 200;
+            res.set_content(json{{"workspaces", arr}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List workspaces error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // POST /api/v1/workspaces -- create workspace
+    svr.Post("/api/v1/workspaces", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+
+            std::string name = body.value("name", "");
+            std::string companyName = body.value("company_name", "");
+
+            if (name.empty() || companyName.empty()) {
+                throw std::invalid_argument("Name and company_name are required");
+            }
+
+            Workspace ws;
+            ws.id = auth::BCryptUtils::generateId("ws");
+            ws.name = name;
+            ws.companyName = companyName;
+            ws.address = body.value("address", "");
+            ws.phone = body.value("phone", "");
+
+            app.workspaceStore()->createWorkspace(ws);
+
+            // Add creator as owner
+            WorkspaceMember member;
+            member.id = auth::BCryptUtils::generateId("wm");
+            member.workspaceId = ws.id;
+            member.userId = authCtx->userId;
+            member.role = "owner";
+            app.workspaceStore()->addMember(member);
+
+            auto created = app.workspaceStore()->getWorkspace(ws.id);
+            res.status = 201;
+            res.set_content(json{{"workspace", created->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Create workspace error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/workspaces/:id -- get workspace (member+)
+    svr.Get(R"(/api/v1/workspaces/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            // Check membership
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            auto ws = app.workspaceStore()->getWorkspace(wsId);
+            if (!ws) {
+                res.status = 404;
+                res.set_content(json{{"error", "Workspace not found"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"workspace", ws->toJson()}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Get workspace error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // PUT /api/v1/workspaces/:id -- update workspace (admin+)
+    svr.Put(R"(/api/v1/workspaces/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+
+            json updateFields = json::object();
+            if (body.contains("name") && body["name"].is_string()) {
+                updateFields["name"] = body["name"];
+            }
+            if (body.contains("company_name") && body["company_name"].is_string()) {
+                updateFields["company_name"] = body["company_name"];
+            }
+            if (body.contains("address") && body["address"].is_string()) {
+                updateFields["address"] = body["address"];
+            }
+            if (body.contains("phone") && body["phone"].is_string()) {
+                updateFields["phone"] = body["phone"];
+            }
+            if (body.contains("settings") && body["settings"].is_object()) {
+                updateFields["settings"] = body["settings"];
+            }
+
+            if (updateFields.empty()) {
+                throw std::invalid_argument("No valid fields to update");
+            }
+
+            bool updated = app.workspaceStore()->updateWorkspace(wsId, updateFields);
+            if (!updated) {
+                res.status = 404;
+                res.set_content(json{{"error", "Workspace not found"}}.dump(), "application/json");
+                return;
+            }
+
+            auto ws = app.workspaceStore()->getWorkspace(wsId);
+            res.status = 200;
+            res.set_content(json{{"workspace", ws->toJson()}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Update workspace error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // DELETE /api/v1/workspaces/:id -- delete workspace (owner only)
+    svr.Delete(R"(/api/v1/workspaces/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            // Owner only
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role != "owner") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: only the owner can delete a workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            bool deleted = app.workspaceStore()->deleteWorkspace(wsId);
+            if (!deleted) {
+                res.status = 404;
+                res.set_content(json{{"error", "Workspace not found"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"message", "Workspace deleted"}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Delete workspace error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // GET /api/v1/workspaces/:id/members -- list members
+    svr.Get(R"(/api/v1/workspaces/([^/]+)/members)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+
+            // Check membership
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+
+            auto members = app.workspaceStore()->listMembers(wsId);
+            json arr = json::array();
+            for (const auto& m : members) {
+                json memberJson = m.toJson();
+                // Enrich with user info
+                auto user = app.userStore()->getUser(m.userId);
+                if (user) {
+                    json userJson = user->toJson();
+                    userJson.erase("password_hash");
+                    memberJson["user"] = userJson;
+                }
+                arr.push_back(memberJson);
+            }
+
+            res.status = 200;
+            res.set_content(json{{"members", arr}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("List members error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // PUT /api/v1/workspaces/:id/members/:uid/role -- change member role (admin+)
+    svr.Put(R"(/api/v1/workspaces/([^/]+)/members/([^/]+)/role)", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string targetUserId = req.matches[2];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            auto body = json::parse(req.body);
+            std::string newRole = body.value("role", "");
+            if (newRole.empty()) {
+                throw std::invalid_argument("Role is required");
+            }
+
+            // Cannot change owner role unless you are the owner
+            if (newRole == "owner" && role != "owner") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: only the owner can assign the owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            // Find the member record
+            auto member = app.workspaceStore()->getMember(wsId, targetUserId);
+            if (!member) {
+                res.status = 404;
+                res.set_content(json{{"error", "Member not found"}}.dump(), "application/json");
+                return;
+            }
+
+            // Cannot change the owner's role
+            if (member->role == "owner" && role != "owner") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: cannot change the owner's role"}}.dump(), "application/json");
+                return;
+            }
+
+            bool updated = app.workspaceStore()->updateMemberRole(member->id, newRole);
+            if (!updated) {
+                res.status = 500;
+                res.set_content(json{{"error", "Failed to update role"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"message", "Role updated"}}.dump(), "application/json");
+
+        } catch (const json::parse_error& e) {
+            res.status = 400;
+            res.set_content(json{{"error", "Invalid JSON"}}.dump(), "application/json");
+        } catch (const std::invalid_argument& e) {
+            res.status = 400;
+            res.set_content(json{{"error", e.what()}}.dump(), "application/json");
+        } catch (const std::exception& e) {
+            spdlog::error("Change member role error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+
+    // DELETE /api/v1/workspaces/:id/members/:uid -- remove member (admin+)
+    svr.Delete(R"(/api/v1/workspaces/([^/]+)/members/([^/]+))", [&app](const httplib::Request& req, httplib::Response& res) {
+        try {
+            auto authCtx = app.authMiddleware()->getAuthContext();
+            if (!authCtx) {
+                res.status = 401;
+                res.set_content(R"({"error":"Unauthorized"})", "application/json");
+                return;
+            }
+
+            std::string wsId = req.matches[1];
+            std::string targetUserId = req.matches[2];
+
+            // Check admin+ role
+            std::string role = app.workspaceService()->getUserRole(authCtx->userId, wsId);
+            if (role.empty()) {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: not a member of this workspace"}}.dump(), "application/json");
+                return;
+            }
+            if (role != "owner" && role != "admin") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: requires admin or owner role"}}.dump(), "application/json");
+                return;
+            }
+
+            // Find the member record
+            auto member = app.workspaceStore()->getMember(wsId, targetUserId);
+            if (!member) {
+                res.status = 404;
+                res.set_content(json{{"error", "Member not found"}}.dump(), "application/json");
+                return;
+            }
+
+            // Cannot remove the owner
+            if (member->role == "owner") {
+                res.status = 403;
+                res.set_content(json{{"error", "Forbidden: cannot remove the workspace owner"}}.dump(), "application/json");
+                return;
+            }
+
+            bool removed = app.workspaceStore()->removeMember(member->id);
+            if (!removed) {
+                res.status = 500;
+                res.set_content(json{{"error", "Failed to remove member"}}.dump(), "application/json");
+                return;
+            }
+
+            res.status = 200;
+            res.set_content(json{{"message", "Member removed"}}.dump(), "application/json");
+
+        } catch (const std::exception& e) {
+            spdlog::error("Remove member error: {}", e.what());
+            res.status = 500;
+            res.set_content(json{{"error", "Internal server error"}}.dump(), "application/json");
+        }
+    });
+}
+
+} // namespace smartbotic::microbit

+ 11 - 0
src/routes/workspace_routes.hpp

@@ -0,0 +1,11 @@
+#pragma once
+
+#include <httplib.h>
+
+namespace smartbotic::microbit {
+
+class App;
+
+void setupWorkspaceRoutes(httplib::Server& svr, App& app);
+
+} // namespace smartbotic::microbit

+ 235 - 0
src/services/assistant_service.cpp

@@ -0,0 +1,235 @@
+#include "assistant_service.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+const std::string AssistantService::DEFAULT_SYSTEM_PROMPT_TEMPLATE =
+    "You are a virtual assistant for {{company_name}}.\n"
+    "{{company_description}}\n\n"
+    "When answering calls, greet the caller with: \"{{greeting_message}}\"\n\n"
+    "If the caller wants to end the conversation, listen for phrases like: {{end_call_phrases}}\n\n"
+    "Company Information:\n"
+    "- Address: {{company_address}}\n"
+    "- Phone: {{company_phone}}\n\n"
+    "Always be polite, professional, and helpful. If you don't know the answer to a question, "
+    "let the caller know and offer to have someone get back to them.";
+
+AssistantService::AssistantService(
+    AssistantStore* assistantStore,
+    callerai::CallerAIClient* calleraiClient)
+    : assistantStore_(assistantStore)
+    , calleraiClient_(calleraiClient) {
+    if (!assistantStore_) {
+        throw std::runtime_error("AssistantService: assistantStore is null");
+    }
+    if (!calleraiClient_) {
+        throw std::runtime_error("AssistantService: calleraiClient is null");
+    }
+}
+
+Assistant AssistantService::createAssistant(
+    const std::string& wsId,
+    const std::string& phoneNumberId,
+    const json& customConfig,
+    const std::string& voiceId,
+    const std::string& voiceProviderConfigId) {
+
+    // Build the CallerAI configuration
+    json calleraiConfig = buildCallerAIConfig(customConfig, voiceId, voiceProviderConfigId);
+
+    // Create the assistant on CallerAI
+    json calleraiResponse = calleraiClient_->createAssistant(calleraiConfig);
+
+    std::string calleraiAssistantId = calleraiResponse.value("id", "");
+    if (calleraiAssistantId.empty()) {
+        throw std::runtime_error("CallerAI did not return an assistant ID");
+    }
+
+    // Assign the assistant to the phone number
+    calleraiClient_->assignAssistantToPhone(phoneNumberId, calleraiAssistantId);
+
+    // Determine the assistant name from config or generate a default
+    std::string name = customConfig.value("name", "");
+    if (name.empty()) {
+        name = customConfig.value("company_name", "Assistant");
+    }
+
+    // Create our local record
+    Assistant assistant;
+    assistant.workspaceId = wsId;
+    assistant.calleraiAssistantId = calleraiAssistantId;
+    assistant.calleraiPhoneNumberId = phoneNumberId;
+    assistant.name = name;
+    assistant.customConfig = customConfig;
+    assistant.voiceId = voiceId;
+    assistant.voiceProviderConfigId = voiceProviderConfigId;
+    assistant.status = "active";
+
+    std::string id = assistantStore_->createAssistant(assistant);
+    assistant.id = id;
+    assistant.createdAt = utils::nowUnixSeconds();
+    assistant.updatedAt = assistant.createdAt;
+
+    return assistant;
+}
+
+void AssistantService::updateAssistantConfig(
+    const std::string& id,
+    const json& customConfig) {
+
+    auto assistantOpt = assistantStore_->getAssistant(id);
+    if (!assistantOpt.has_value()) {
+        throw std::runtime_error("Assistant not found");
+    }
+
+    const Assistant& assistant = assistantOpt.value();
+
+    // Rebuild the CallerAI config with updated custom settings
+    json calleraiConfig = buildCallerAIConfig(
+        customConfig, assistant.voiceId, assistant.voiceProviderConfigId);
+
+    // Update on CallerAI
+    calleraiClient_->updateAssistant(assistant.calleraiAssistantId, calleraiConfig);
+
+    // Update local record
+    json fields = {{"custom_config", customConfig}};
+
+    // Update the name if provided
+    if (customConfig.contains("name") && customConfig["name"].is_string()) {
+        fields["name"] = customConfig["name"];
+    }
+
+    assistantStore_->updateAssistant(id, fields);
+}
+
+void AssistantService::updateVoice(
+    const std::string& id,
+    const std::string& voiceId,
+    const std::string& voiceProviderConfigId) {
+
+    auto assistantOpt = assistantStore_->getAssistant(id);
+    if (!assistantOpt.has_value()) {
+        throw std::runtime_error("Assistant not found");
+    }
+
+    const Assistant& assistant = assistantOpt.value();
+
+    // Rebuild config with new voice
+    json calleraiConfig = buildCallerAIConfig(
+        assistant.customConfig, voiceId, voiceProviderConfigId);
+
+    // Update on CallerAI
+    calleraiClient_->updateAssistant(assistant.calleraiAssistantId, calleraiConfig);
+
+    // Update local record
+    json fields = {
+        {"voice_id", voiceId},
+        {"voice_provider_config_id", voiceProviderConfigId}
+    };
+    assistantStore_->updateAssistant(id, fields);
+}
+
+void AssistantService::deleteAssistant(const std::string& id) {
+    auto assistantOpt = assistantStore_->getAssistant(id);
+    if (!assistantOpt.has_value()) {
+        throw std::runtime_error("Assistant not found");
+    }
+
+    const Assistant& assistant = assistantOpt.value();
+
+    // Delete from CallerAI
+    calleraiClient_->deleteAssistant(assistant.calleraiAssistantId);
+
+    // Delete local record
+    assistantStore_->deleteAssistant(id);
+}
+
+json AssistantService::listAvailablePhoneNumbers() {
+    return calleraiClient_->listPhoneNumbers();
+}
+
+json AssistantService::listAvailableVoices(const std::string& providerConfigId) {
+    return calleraiClient_->listVoices(providerConfigId);
+}
+
+json AssistantService::listProviderConfigs() {
+    return calleraiClient_->listProviderConfigs();
+}
+
+std::string AssistantService::renderTemplate(
+    const std::string& tmpl,
+    const json& vars) {
+
+    std::string result = tmpl;
+    std::string::size_type pos = 0;
+
+    while ((pos = result.find("{{", pos)) != std::string::npos) {
+        auto end = result.find("}}", pos);
+        if (end == std::string::npos) {
+            break;
+        }
+
+        std::string key = result.substr(pos + 2, end - pos - 2);
+        std::string value;
+
+        if (vars.contains(key) && vars[key].is_string()) {
+            value = vars[key].get<std::string>();
+        }
+
+        result.replace(pos, end - pos + 2, value);
+        pos += value.length();
+    }
+
+    return result;
+}
+
+json AssistantService::buildCallerAIConfig(
+    const json& customConfig,
+    const std::string& voiceId,
+    const std::string& voiceProviderConfigId) {
+
+    // Render the system prompt from the template
+    std::string systemPrompt;
+    if (customConfig.contains("system_prompt") && customConfig["system_prompt"].is_string()) {
+        systemPrompt = customConfig["system_prompt"].get<std::string>();
+    } else {
+        systemPrompt = renderTemplate(DEFAULT_SYSTEM_PROMPT_TEMPLATE, customConfig);
+    }
+
+    // Build the CallerAI assistant configuration
+    json config = {
+        {"model", {
+            {"provider", "openai"},
+            {"model", "gpt-4"},
+            {"systemMessage", systemPrompt}
+        }},
+        {"voice", {
+            {"voiceId", voiceId},
+            {"providerConfigId", voiceProviderConfigId}
+        }}
+    };
+
+    // Include first message / greeting if specified
+    if (customConfig.contains("first_message") && customConfig["first_message"].is_string()) {
+        config["firstMessage"] = customConfig["first_message"];
+    }
+
+    // Include end-call configuration if specified
+    if (customConfig.contains("end_call_phrases") && customConfig["end_call_phrases"].is_array()) {
+        config["endCallPhrases"] = customConfig["end_call_phrases"];
+    }
+
+    // Include any additional CallerAI-specific overrides
+    if (customConfig.contains("callerai_overrides") && customConfig["callerai_overrides"].is_object()) {
+        for (auto& [key, value] : customConfig["callerai_overrides"].items()) {
+            config[key] = value;
+        }
+    }
+
+    return config;
+}
+
+} // namespace smartbotic::microbit

+ 48 - 0
src/services/assistant_service.hpp

@@ -0,0 +1,48 @@
+#pragma once
+
+#include "../stores/assistant_store.hpp"
+
+#include <smartbotic/microbit/callerai/callerai_client.hpp>
+
+#include <nlohmann/json.hpp>
+
+#include <string>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+class AssistantService {
+public:
+    AssistantService(AssistantStore* assistantStore,
+                     callerai::CallerAIClient* calleraiClient);
+
+    static const std::string DEFAULT_SYSTEM_PROMPT_TEMPLATE;
+
+    Assistant createAssistant(const std::string& wsId,
+                              const std::string& phoneNumberId,
+                              const json& customConfig,
+                              const std::string& voiceId,
+                              const std::string& voiceProviderConfigId);
+
+    void updateAssistantConfig(const std::string& id, const json& customConfig);
+    void updateVoice(const std::string& id,
+                     const std::string& voiceId,
+                     const std::string& voiceProviderConfigId);
+    void deleteAssistant(const std::string& id);
+
+    json listAvailablePhoneNumbers();
+    json listAvailableVoices(const std::string& providerConfigId);
+    json listProviderConfigs();
+
+private:
+    AssistantStore* assistantStore_;
+    callerai::CallerAIClient* calleraiClient_;
+
+    std::string renderTemplate(const std::string& tmpl, const json& vars);
+    json buildCallerAIConfig(const json& customConfig,
+                             const std::string& voiceId,
+                             const std::string& voiceProviderConfigId);
+};
+
+} // namespace smartbotic::microbit

+ 191 - 0
src/services/auth_service.cpp

@@ -0,0 +1,191 @@
+#include "auth_service.hpp"
+
+#include <smartbotic/microbit/auth/bcrypt_utils.hpp>
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+AuthService::AuthService(UserStore* userStore, auth::AuthMiddleware* authMiddleware)
+    : userStore_(userStore)
+    , authMiddleware_(authMiddleware) {
+    if (!userStore_) {
+        throw std::runtime_error("AuthService: userStore is null");
+    }
+    if (!authMiddleware_) {
+        throw std::runtime_error("AuthService: authMiddleware is null");
+    }
+}
+
+AuthService::RegisterResult AuthService::registerUser(
+    const std::string& email,
+    const std::string& password,
+    const std::string& displayName) {
+
+    // Validate password against policy
+    std::string passwordError = authMiddleware_->validatePassword(password);
+    if (!passwordError.empty()) {
+        throw std::runtime_error("Invalid password: " + passwordError);
+    }
+
+    // Check if email is already taken
+    auto existing = userStore_->getUserByEmail(email);
+    if (existing.has_value()) {
+        throw std::runtime_error("A user with this email already exists");
+    }
+
+    // Check if this is the first user in the system
+    bool isFirstUser = !userStore_->hasAnyUsers();
+
+    // Hash the password
+    std::string passwordHash = auth::BCryptUtils::hashPassword(password);
+
+    // Create the user
+    User user;
+    user.email = email;
+    user.passwordHash = passwordHash;
+    user.displayName = displayName;
+    user.status = "active";
+
+    std::string userId = userStore_->createUser(user);
+    user.id = userId;
+    user.createdAt = utils::nowUnixSeconds();
+    user.updatedAt = user.createdAt;
+
+    // Create session and tokens
+    LoginResult loginResult = createSessionAndTokens(user, "registration");
+
+    RegisterResult result;
+    result.user = loginResult.user;
+    result.accessToken = loginResult.accessToken;
+    result.refreshToken = loginResult.refreshToken;
+    return result;
+}
+
+AuthService::LoginResult AuthService::login(
+    const std::string& email,
+    const std::string& password,
+    const std::string& deviceInfo) {
+
+    // Find user by email
+    auto userOpt = userStore_->getUserByEmail(email);
+    if (!userOpt.has_value()) {
+        throw std::runtime_error("Invalid email or password");
+    }
+
+    User user = userOpt.value();
+
+    // Check user status
+    if (user.status != "active") {
+        throw std::runtime_error("Account is not active");
+    }
+
+    // Verify password
+    if (!auth::BCryptUtils::verifyPassword(password, user.passwordHash)) {
+        throw std::runtime_error("Invalid email or password");
+    }
+
+    return createSessionAndTokens(user, deviceInfo);
+}
+
+std::string AuthService::refreshAccessToken(const std::string& refreshToken) {
+    // Validate the refresh token
+    auto context = authMiddleware_->validateToken(refreshToken);
+    if (!context.has_value()) {
+        throw std::runtime_error("Invalid or expired refresh token");
+    }
+
+    // The refresh token's sub field contains the userId
+    std::string userId = context->userId;
+
+    // Verify the user still exists and is active
+    auto userOpt = userStore_->getUser(userId);
+    if (!userOpt.has_value()) {
+        throw std::runtime_error("User not found");
+    }
+
+    const User& user = userOpt.value();
+    if (user.status != "active") {
+        throw std::runtime_error("Account is not active");
+    }
+
+    // Create a new access token
+    std::string accessToken = authMiddleware_->createAccessToken(
+        user.id, user.email, "user");
+
+    return accessToken;
+}
+
+void AuthService::logout(const std::string& sessionId) {
+    userStore_->deleteSession(sessionId);
+}
+
+void AuthService::changePassword(
+    const std::string& userId,
+    const std::string& currentPassword,
+    const std::string& newPassword) {
+
+    // Get the user
+    auto userOpt = userStore_->getUser(userId);
+    if (!userOpt.has_value()) {
+        throw std::runtime_error("User not found");
+    }
+
+    const User& user = userOpt.value();
+
+    // Verify current password
+    if (!auth::BCryptUtils::verifyPassword(currentPassword, user.passwordHash)) {
+        throw std::runtime_error("Current password is incorrect");
+    }
+
+    // Validate new password
+    std::string passwordError = authMiddleware_->validatePassword(newPassword);
+    if (!passwordError.empty()) {
+        throw std::runtime_error("Invalid new password: " + passwordError);
+    }
+
+    // Hash and update
+    std::string newHash = auth::BCryptUtils::hashPassword(newPassword);
+    json fields = {{"password_hash", newHash}};
+    bool updated = userStore_->updateUser(userId, fields);
+    if (!updated) {
+        throw std::runtime_error("Failed to update password");
+    }
+
+    // Invalidate all existing sessions to force re-login
+    userStore_->deleteUserSessions(userId);
+}
+
+AuthService::LoginResult AuthService::createSessionAndTokens(
+    const User& user,
+    const std::string& deviceInfo) {
+
+    // Create a session
+    Session session;
+    session.userId = user.id;
+    session.deviceInfo = deviceInfo;
+    session.expiresAt = utils::nowUnixSeconds() +
+        authMiddleware_->config().refreshTokenLifetimeSec;
+
+    std::string sessionId = userStore_->createSession(session);
+
+    // Generate tokens
+    std::string accessToken = authMiddleware_->createAccessToken(
+        user.id, user.email, "user");
+    std::string refreshToken = authMiddleware_->createRefreshToken(
+        user.id, sessionId);
+
+    // Store the refresh token hash in the session
+    std::string tokenHash = auth::BCryptUtils::hashPassword(refreshToken);
+    json sessionUpdate = {{"refresh_token_hash", tokenHash}};
+    userStore_->updateUser(user.id, json::object()); // touch updated_at
+
+    LoginResult result;
+    result.accessToken = accessToken;
+    result.refreshToken = refreshToken;
+    result.user = user;
+    return result;
+}
+
+} // namespace smartbotic::microbit

+ 50 - 0
src/services/auth_service.hpp

@@ -0,0 +1,50 @@
+#pragma once
+
+#include "../stores/user_store.hpp"
+
+#include <smartbotic/microbit/auth/auth_middleware.hpp>
+
+#include <string>
+
+namespace smartbotic::microbit {
+
+class AuthService {
+public:
+    struct LoginResult {
+        std::string accessToken;
+        std::string refreshToken;
+        User user;
+    };
+
+    struct RegisterResult {
+        User user;
+        std::string accessToken;
+        std::string refreshToken;
+    };
+
+    AuthService(UserStore* userStore, auth::AuthMiddleware* authMiddleware);
+
+    RegisterResult registerUser(const std::string& email,
+                                const std::string& password,
+                                const std::string& displayName);
+
+    LoginResult login(const std::string& email,
+                      const std::string& password,
+                      const std::string& deviceInfo);
+
+    std::string refreshAccessToken(const std::string& refreshToken);
+
+    void logout(const std::string& sessionId);
+
+    void changePassword(const std::string& userId,
+                        const std::string& currentPassword,
+                        const std::string& newPassword);
+
+private:
+    UserStore* userStore_;
+    auth::AuthMiddleware* authMiddleware_;
+
+    LoginResult createSessionAndTokens(const User& user, const std::string& deviceInfo);
+};
+
+} // namespace smartbotic::microbit

+ 273 - 0
src/services/calendar_service.cpp

@@ -0,0 +1,273 @@
+#include "calendar_service.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <algorithm>
+#include <ctime>
+#include <stdexcept>
+#include <unordered_set>
+
+namespace smartbotic::microbit {
+
+CalendarService::CalendarService(CalendarStore* calendarStore)
+    : calendarStore_(calendarStore) {
+    if (!calendarStore_) {
+        throw std::runtime_error("CalendarService: calendarStore is null");
+    }
+}
+
+Calendar CalendarService::createCalendar(
+    const std::string& wsId,
+    const std::string& name,
+    const std::string& tz) {
+
+    if (name.empty()) {
+        throw std::runtime_error("Calendar name is required");
+    }
+    if (tz.empty()) {
+        throw std::runtime_error("Timezone is required");
+    }
+
+    Calendar calendar;
+    calendar.workspaceId = wsId;
+    calendar.name = name;
+    calendar.timezone = tz;
+
+    std::string id = calendarStore_->createCalendar(calendar);
+    calendar.id = id;
+    calendar.createdAt = utils::nowUnixSeconds();
+
+    return calendar;
+}
+
+void CalendarService::updateCalendar(const std::string& id, const json& fields) {
+    auto calOpt = calendarStore_->getCalendar(id);
+    if (!calOpt.has_value()) {
+        throw std::runtime_error("Calendar not found");
+    }
+
+    bool updated = calendarStore_->updateCalendar(id, fields);
+    if (!updated) {
+        throw std::runtime_error("Failed to update calendar");
+    }
+}
+
+void CalendarService::deleteCalendar(const std::string& id) {
+    auto calOpt = calendarStore_->getCalendar(id);
+    if (!calOpt.has_value()) {
+        throw std::runtime_error("Calendar not found");
+    }
+
+    bool deleted = calendarStore_->deleteCalendar(id);
+    if (!deleted) {
+        throw std::runtime_error("Failed to delete calendar");
+    }
+}
+
+TimeSlot CalendarService::createTimeSlot(
+    const std::string& calId,
+    const std::string& wsId,
+    const json& data) {
+
+    // Verify calendar exists
+    auto calOpt = calendarStore_->getCalendar(calId);
+    if (!calOpt.has_value()) {
+        throw std::runtime_error("Calendar not found");
+    }
+
+    TimeSlot slot;
+    slot.calendarId = calId;
+    slot.workspaceId = wsId;
+    slot.dayOfWeek = data.value("day_of_week", 0);
+    slot.startTime = data.value("start_time", "");
+    slot.endTime = data.value("end_time", "");
+    slot.isActive = data.value("is_active", true);
+
+    if (slot.dayOfWeek < 0 || slot.dayOfWeek > 6) {
+        throw std::runtime_error("day_of_week must be between 0 (Sunday) and 6 (Saturday)");
+    }
+    if (slot.startTime.empty() || slot.endTime.empty()) {
+        throw std::runtime_error("start_time and end_time are required (HH:MM format)");
+    }
+    if (slot.startTime >= slot.endTime) {
+        throw std::runtime_error("start_time must be before end_time");
+    }
+
+    std::string id = calendarStore_->createTimeSlot(slot);
+    slot.id = id;
+
+    return slot;
+}
+
+void CalendarService::updateTimeSlot(const std::string& id, const json& fields) {
+    auto slotOpt = calendarStore_->getTimeSlot(id);
+    if (!slotOpt.has_value()) {
+        throw std::runtime_error("Time slot not found");
+    }
+
+    bool updated = calendarStore_->updateTimeSlot(id, fields);
+    if (!updated) {
+        throw std::runtime_error("Failed to update time slot");
+    }
+}
+
+void CalendarService::deleteTimeSlot(const std::string& id) {
+    auto slotOpt = calendarStore_->getTimeSlot(id);
+    if (!slotOpt.has_value()) {
+        throw std::runtime_error("Time slot not found");
+    }
+
+    bool deleted = calendarStore_->deleteTimeSlot(id);
+    if (!deleted) {
+        throw std::runtime_error("Failed to delete time slot");
+    }
+}
+
+std::vector<TimeSlot> CalendarService::getAvailableSlots(
+    const std::string& calId,
+    const std::string& date) {
+
+    // Determine the day of the week for the given date
+    int dow = dayOfWeekFromDate(date);
+
+    // Get all time slots for this calendar
+    auto allSlots = calendarStore_->listSlotsByCalendar(calId);
+
+    // Filter to active slots matching the day of week
+    std::vector<TimeSlot> matchingSlots;
+    for (const auto& slot : allSlots) {
+        if (slot.isActive && slot.dayOfWeek == dow) {
+            matchingSlots.push_back(slot);
+        }
+    }
+
+    // Get existing booked appointments for this calendar and date
+    auto appointments = calendarStore_->listAppointmentsByCalendarAndDate(calId, date);
+
+    // Build a set of time slot IDs that already have booked appointments
+    std::unordered_set<std::string> bookedSlotIds;
+    for (const auto& apt : appointments) {
+        if (apt.status == "booked") {
+            bookedSlotIds.insert(apt.timeSlotId);
+        }
+    }
+
+    // Filter out slots that are already booked
+    std::vector<TimeSlot> availableSlots;
+    for (const auto& slot : matchingSlots) {
+        if (bookedSlotIds.find(slot.id) == bookedSlotIds.end()) {
+            availableSlots.push_back(slot);
+        }
+    }
+
+    return availableSlots;
+}
+
+Appointment CalendarService::bookAppointment(
+    const std::string& calId,
+    const std::string& wsId,
+    const std::string& tsId,
+    const std::string& date,
+    const json& callerInfo) {
+
+    // Verify calendar exists
+    auto calOpt = calendarStore_->getCalendar(calId);
+    if (!calOpt.has_value()) {
+        throw std::runtime_error("Calendar not found");
+    }
+
+    // Verify time slot exists and belongs to this calendar
+    auto slotOpt = calendarStore_->getTimeSlot(tsId);
+    if (!slotOpt.has_value()) {
+        throw std::runtime_error("Time slot not found");
+    }
+
+    const TimeSlot& slot = slotOpt.value();
+    if (slot.calendarId != calId) {
+        throw std::runtime_error("Time slot does not belong to this calendar");
+    }
+    if (!slot.isActive) {
+        throw std::runtime_error("Time slot is not active");
+    }
+
+    // Verify the day of week matches
+    int dow = dayOfWeekFromDate(date);
+    if (slot.dayOfWeek != dow) {
+        throw std::runtime_error("Date does not match the time slot's day of week");
+    }
+
+    // Check if the slot is already booked for this date
+    auto existingAppointments = calendarStore_->listAppointmentsByCalendarAndDate(calId, date);
+    for (const auto& apt : existingAppointments) {
+        if (apt.timeSlotId == tsId && apt.status == "booked") {
+            throw std::runtime_error("This time slot is already booked for the given date");
+        }
+    }
+
+    // Create the appointment
+    Appointment appointment;
+    appointment.calendarId = calId;
+    appointment.workspaceId = wsId;
+    appointment.timeSlotId = tsId;
+    appointment.date = date;
+    appointment.callerName = callerInfo.value("caller_name", "");
+    appointment.callerPhone = callerInfo.value("caller_phone", "");
+    appointment.notes = callerInfo.value("notes", "");
+    appointment.status = "booked";
+
+    std::string id = calendarStore_->createAppointment(appointment);
+    appointment.id = id;
+    appointment.createdAt = utils::nowUnixSeconds();
+
+    return appointment;
+}
+
+void CalendarService::cancelAppointment(const std::string& id) {
+    auto aptOpt = calendarStore_->getAppointment(id);
+    if (!aptOpt.has_value()) {
+        throw std::runtime_error("Appointment not found");
+    }
+
+    const Appointment& appointment = aptOpt.value();
+    if (appointment.status == "cancelled") {
+        throw std::runtime_error("Appointment is already cancelled");
+    }
+
+    json fields = {{"status", "cancelled"}};
+    bool updated = calendarStore_->updateAppointment(id, fields);
+    if (!updated) {
+        throw std::runtime_error("Failed to cancel appointment");
+    }
+}
+
+std::vector<Appointment> CalendarService::listAppointments(
+    const std::string& wsId,
+    const std::string& dateFrom,
+    const std::string& dateTo) {
+
+    return calendarStore_->listAppointmentsByWorkspace(wsId, dateFrom, dateTo);
+}
+
+int CalendarService::dayOfWeekFromDate(const std::string& dateStr) {
+    // Parse "YYYY-MM-DD" format
+    if (dateStr.length() != 10 || dateStr[4] != '-' || dateStr[7] != '-') {
+        throw std::runtime_error("Invalid date format. Expected YYYY-MM-DD");
+    }
+
+    int year = std::stoi(dateStr.substr(0, 4));
+    int month = std::stoi(dateStr.substr(5, 2));
+    int day = std::stoi(dateStr.substr(8, 2));
+
+    struct std::tm timeinfo = {};
+    timeinfo.tm_year = year - 1900;
+    timeinfo.tm_mon = month - 1;
+    timeinfo.tm_mday = day;
+    timeinfo.tm_isdst = -1;
+
+    std::mktime(&timeinfo);
+
+    // tm_wday is 0=Sunday, 1=Monday, ..., 6=Saturday
+    return timeinfo.tm_wday;
+}
+
+} // namespace smartbotic::microbit

+ 49 - 0
src/services/calendar_service.hpp

@@ -0,0 +1,49 @@
+#pragma once
+
+#include "../stores/calendar_store.hpp"
+
+#include <nlohmann/json.hpp>
+
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+class CalendarService {
+public:
+    explicit CalendarService(CalendarStore* calendarStore);
+
+    Calendar createCalendar(const std::string& wsId,
+                            const std::string& name,
+                            const std::string& tz);
+    void updateCalendar(const std::string& id, const json& fields);
+    void deleteCalendar(const std::string& id);
+
+    TimeSlot createTimeSlot(const std::string& calId,
+                            const std::string& wsId,
+                            const json& data);
+    void updateTimeSlot(const std::string& id, const json& fields);
+    void deleteTimeSlot(const std::string& id);
+
+    std::vector<TimeSlot> getAvailableSlots(const std::string& calId,
+                                            const std::string& date);
+
+    Appointment bookAppointment(const std::string& calId,
+                                const std::string& wsId,
+                                const std::string& tsId,
+                                const std::string& date,
+                                const json& callerInfo);
+    void cancelAppointment(const std::string& id);
+    std::vector<Appointment> listAppointments(const std::string& wsId,
+                                              const std::string& dateFrom,
+                                              const std::string& dateTo);
+
+private:
+    CalendarStore* calendarStore_;
+
+    static int dayOfWeekFromDate(const std::string& dateStr);
+};
+
+} // namespace smartbotic::microbit

+ 234 - 0
src/services/invitation_service.cpp

@@ -0,0 +1,234 @@
+#include "invitation_service.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+InvitationService::InvitationService(
+    InvitationStore* invitationStore,
+    WorkspaceStore* workspaceStore,
+    UserStore* userStore,
+    smtp::SmtpClient* smtpClient)
+    : invitationStore_(invitationStore)
+    , workspaceStore_(workspaceStore)
+    , userStore_(userStore)
+    , smtpClient_(smtpClient) {
+    if (!invitationStore_) {
+        throw std::runtime_error("InvitationService: invitationStore is null");
+    }
+    if (!workspaceStore_) {
+        throw std::runtime_error("InvitationService: workspaceStore is null");
+    }
+    if (!userStore_) {
+        throw std::runtime_error("InvitationService: userStore is null");
+    }
+    if (!smtpClient_) {
+        throw std::runtime_error("InvitationService: smtpClient is null");
+    }
+}
+
+Invitation InvitationService::inviteUser(
+    const std::string& wsId,
+    const std::string& email,
+    const std::string& role,
+    const std::string& invitedBy,
+    const std::string& appBaseUrl) {
+
+    // Verify workspace exists
+    auto wsOpt = workspaceStore_->getWorkspace(wsId);
+    if (!wsOpt.has_value()) {
+        throw std::runtime_error("Workspace not found");
+    }
+    const Workspace& workspace = wsOpt.value();
+
+    // Check if user is already a member
+    auto existingUser = userStore_->getUserByEmail(email);
+    if (existingUser.has_value()) {
+        auto existingMember = workspaceStore_->getMember(wsId, existingUser->id);
+        if (existingMember.has_value()) {
+            throw std::runtime_error("User is already a member of this workspace");
+        }
+    }
+
+    // Check for existing pending invitation
+    auto pendingInvitations = invitationStore_->listPendingByEmail(email);
+    for (const auto& inv : pendingInvitations) {
+        if (inv.workspaceId == wsId) {
+            throw std::runtime_error("A pending invitation already exists for this email");
+        }
+    }
+
+    // Create the invitation
+    Invitation invitation;
+    invitation.workspaceId = wsId;
+    invitation.email = email;
+    invitation.token = utils::randomHex(32);
+    invitation.status = "pending";
+    invitation.role = role;
+    invitation.invitedBy = invitedBy;
+    invitation.expiresAt = utils::nowUnixSeconds() + (7 * 24 * 3600); // 7 days
+
+    std::string invId = invitationStore_->createInvitation(invitation);
+    invitation.id = invId;
+    invitation.createdAt = utils::nowUnixSeconds();
+
+    // Send email
+    bool isExistingUser = existingUser.has_value();
+    sendInvitationEmail(invitation, workspace.name, isExistingUser, appBaseUrl);
+
+    return invitation;
+}
+
+void InvitationService::acceptInvitation(
+    const std::string& token,
+    const std::string& userId) {
+
+    // Find the invitation by token
+    auto invOpt = invitationStore_->getInvitationByToken(token);
+    if (!invOpt.has_value()) {
+        throw std::runtime_error("Invitation not found");
+    }
+
+    Invitation invitation = invOpt.value();
+
+    // Check status
+    if (invitation.status != "pending") {
+        throw std::runtime_error("Invitation has already been " + invitation.status);
+    }
+
+    // Check expiration
+    uint64_t now = utils::nowUnixSeconds();
+    if (invitation.expiresAt > 0 && now > invitation.expiresAt) {
+        throw std::runtime_error("Invitation has expired");
+    }
+
+    // Add user as member of the workspace
+    WorkspaceMember member;
+    member.workspaceId = invitation.workspaceId;
+    member.userId = userId;
+    member.role = invitation.role;
+    member.invitedBy = invitation.invitedBy;
+
+    workspaceStore_->addMember(member);
+
+    // Update invitation status
+    invitationStore_->updateStatus(invitation.id, "accepted");
+}
+
+void InvitationService::declineInvitation(
+    const std::string& token,
+    const std::string& userId) {
+
+    auto invOpt = invitationStore_->getInvitationByToken(token);
+    if (!invOpt.has_value()) {
+        throw std::runtime_error("Invitation not found");
+    }
+
+    Invitation invitation = invOpt.value();
+
+    if (invitation.status != "pending") {
+        throw std::runtime_error("Invitation has already been " + invitation.status);
+    }
+
+    // Verify the user matches the invitation email
+    auto userOpt = userStore_->getUser(userId);
+    if (!userOpt.has_value()) {
+        throw std::runtime_error("User not found");
+    }
+
+    if (userOpt->email != invitation.email) {
+        throw std::runtime_error("Invitation does not belong to this user");
+    }
+
+    invitationStore_->updateStatus(invitation.id, "declined");
+}
+
+std::vector<Invitation> InvitationService::listWorkspaceInvitations(
+    const std::string& wsId) {
+    return invitationStore_->listWorkspaceInvitations(wsId);
+}
+
+std::vector<Invitation> InvitationService::listPendingForUser(
+    const std::string& email) {
+    return invitationStore_->listPendingByEmail(email);
+}
+
+void InvitationService::processRegistrationInvites(
+    const std::string& email,
+    const std::string& userId) {
+
+    // Find all pending invitations for this email
+    auto pendingInvitations = invitationStore_->listPendingByEmail(email);
+
+    for (const auto& invitation : pendingInvitations) {
+        // Check if not expired
+        uint64_t now = utils::nowUnixSeconds();
+        if (invitation.expiresAt > 0 && now > invitation.expiresAt) {
+            continue;
+        }
+
+        // Add user to the workspace
+        WorkspaceMember member;
+        member.workspaceId = invitation.workspaceId;
+        member.userId = userId;
+        member.role = invitation.role;
+        member.invitedBy = invitation.invitedBy;
+
+        workspaceStore_->addMember(member);
+
+        // Mark invitation as accepted
+        invitationStore_->updateStatus(invitation.id, "accepted");
+    }
+}
+
+void InvitationService::sendInvitationEmail(
+    const Invitation& invitation,
+    const std::string& workspaceName,
+    bool isExistingUser,
+    const std::string& appBaseUrl) {
+
+    std::string subject = "You've been invited to join " + workspaceName;
+    std::string inviteUrl = appBaseUrl + "/invitations/accept?token=" + invitation.token;
+
+    std::string htmlBody;
+    std::string textBody;
+
+    if (isExistingUser) {
+        htmlBody =
+            "<h2>Workspace Invitation</h2>"
+            "<p>You've been invited to join <strong>" + workspaceName + "</strong> "
+            "as a <strong>" + invitation.role + "</strong>.</p>"
+            "<p>Click the link below to accept the invitation:</p>"
+            "<p><a href=\"" + inviteUrl + "\">Accept Invitation</a></p>"
+            "<p>This invitation will expire in 7 days.</p>";
+
+        textBody =
+            "Workspace Invitation\n\n"
+            "You've been invited to join " + workspaceName + " as a " + invitation.role + ".\n\n"
+            "Accept the invitation: " + inviteUrl + "\n\n"
+            "This invitation will expire in 7 days.";
+    } else {
+        std::string registerUrl = appBaseUrl + "/register?invite=" + invitation.token +
+            "&email=" + invitation.email;
+
+        htmlBody =
+            "<h2>Workspace Invitation</h2>"
+            "<p>You've been invited to join <strong>" + workspaceName + "</strong> "
+            "as a <strong>" + invitation.role + "</strong>.</p>"
+            "<p>You'll need to create an account first. Click the link below to get started:</p>"
+            "<p><a href=\"" + registerUrl + "\">Create Account & Accept Invitation</a></p>"
+            "<p>This invitation will expire in 7 days.</p>";
+
+        textBody =
+            "Workspace Invitation\n\n"
+            "You've been invited to join " + workspaceName + " as a " + invitation.role + ".\n\n"
+            "Create your account and accept: " + registerUrl + "\n\n"
+            "This invitation will expire in 7 days.";
+    }
+
+    smtpClient_->sendEmail(invitation.email, subject, htmlBody, textBody);
+}
+
+} // namespace smartbotic::microbit

+ 45 - 0
src/services/invitation_service.hpp

@@ -0,0 +1,45 @@
+#pragma once
+
+#include "../stores/invitation_store.hpp"
+#include "../stores/workspace_store.hpp"
+#include "../stores/user_store.hpp"
+
+#include <smartbotic/microbit/smtp/smtp_client.hpp>
+
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+class InvitationService {
+public:
+    InvitationService(InvitationStore* invitationStore,
+                      WorkspaceStore* workspaceStore,
+                      UserStore* userStore,
+                      smtp::SmtpClient* smtpClient);
+
+    Invitation inviteUser(const std::string& wsId,
+                          const std::string& email,
+                          const std::string& role,
+                          const std::string& invitedBy,
+                          const std::string& appBaseUrl);
+
+    void acceptInvitation(const std::string& token, const std::string& userId);
+    void declineInvitation(const std::string& token, const std::string& userId);
+    std::vector<Invitation> listWorkspaceInvitations(const std::string& wsId);
+    std::vector<Invitation> listPendingForUser(const std::string& email);
+    void processRegistrationInvites(const std::string& email, const std::string& userId);
+
+private:
+    InvitationStore* invitationStore_;
+    WorkspaceStore* workspaceStore_;
+    UserStore* userStore_;
+    smtp::SmtpClient* smtpClient_;
+
+    void sendInvitationEmail(const Invitation& invitation,
+                             const std::string& workspaceName,
+                             bool isExistingUser,
+                             const std::string& appBaseUrl);
+};
+
+} // namespace smartbotic::microbit

+ 113 - 0
src/services/workspace_service.cpp

@@ -0,0 +1,113 @@
+#include "workspace_service.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <algorithm>
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+WorkspaceService::WorkspaceService(WorkspaceStore* workspaceStore)
+    : workspaceStore_(workspaceStore) {
+    if (!workspaceStore_) {
+        throw std::runtime_error("WorkspaceService: workspaceStore is null");
+    }
+}
+
+Workspace WorkspaceService::createWorkspace(const std::string& userId, const json& data) {
+    // Build the workspace from the input data
+    Workspace workspace;
+    workspace.name = data.value("name", "");
+    workspace.companyName = data.value("company_name", "");
+    workspace.address = data.value("address", "");
+    workspace.phone = data.value("phone", "");
+    workspace.settings = data.value("settings", json::object());
+
+    if (workspace.name.empty()) {
+        throw std::runtime_error("Workspace name is required");
+    }
+
+    // Create the workspace
+    std::string wsId = workspaceStore_->createWorkspace(workspace);
+
+    // Add the creating user as the owner
+    WorkspaceMember member;
+    member.workspaceId = wsId;
+    member.userId = userId;
+    member.role = "owner";
+    member.invitedBy = userId;
+
+    workspaceStore_->addMember(member);
+
+    // Return the created workspace
+    auto created = workspaceStore_->getWorkspace(wsId);
+    if (!created.has_value()) {
+        throw std::runtime_error("Failed to retrieve created workspace");
+    }
+    return created.value();
+}
+
+void WorkspaceService::updateWorkspace(
+    const std::string& userId,
+    const std::string& wsId,
+    const json& data) {
+
+    // Only owner or admin can update
+    requireRole(userId, wsId, {"owner", "admin"});
+
+    bool updated = workspaceStore_->updateWorkspace(wsId, data);
+    if (!updated) {
+        throw std::runtime_error("Failed to update workspace");
+    }
+}
+
+void WorkspaceService::deleteWorkspace(
+    const std::string& userId,
+    const std::string& wsId) {
+
+    // Only the owner can delete a workspace
+    requireRole(userId, wsId, {"owner"});
+
+    bool deleted = workspaceStore_->deleteWorkspace(wsId);
+    if (!deleted) {
+        throw std::runtime_error("Failed to delete workspace");
+    }
+}
+
+std::string WorkspaceService::getUserRole(
+    const std::string& userId,
+    const std::string& wsId) {
+
+    auto member = workspaceStore_->getMember(wsId, userId);
+    if (!member.has_value()) {
+        return "";
+    }
+    return member->role;
+}
+
+bool WorkspaceService::canManageMembers(
+    const std::string& userId,
+    const std::string& wsId) {
+
+    std::string role = getUserRole(userId, wsId);
+    return role == "owner" || role == "admin";
+}
+
+void WorkspaceService::requireRole(
+    const std::string& userId,
+    const std::string& wsId,
+    const std::vector<std::string>& allowedRoles) {
+
+    std::string role = getUserRole(userId, wsId);
+    if (role.empty()) {
+        throw std::runtime_error("User is not a member of this workspace");
+    }
+
+    auto it = std::find(allowedRoles.begin(), allowedRoles.end(), role);
+    if (it == allowedRoles.end()) {
+        throw std::runtime_error("Insufficient permissions. Required role: " +
+            allowedRoles.front() + " (or higher)");
+    }
+}
+
+} // namespace smartbotic::microbit

+ 33 - 0
src/services/workspace_service.hpp

@@ -0,0 +1,33 @@
+#pragma once
+
+#include "../stores/workspace_store.hpp"
+
+#include <nlohmann/json.hpp>
+
+#include <string>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+class WorkspaceService {
+public:
+    explicit WorkspaceService(WorkspaceStore* workspaceStore);
+
+    Workspace createWorkspace(const std::string& userId, const json& data);
+    void updateWorkspace(const std::string& userId,
+                         const std::string& wsId,
+                         const json& data);
+    void deleteWorkspace(const std::string& userId, const std::string& wsId);
+    std::string getUserRole(const std::string& userId, const std::string& wsId);
+    bool canManageMembers(const std::string& userId, const std::string& wsId);
+
+private:
+    WorkspaceStore* workspaceStore_;
+
+    void requireRole(const std::string& userId,
+                     const std::string& wsId,
+                     const std::vector<std::string>& allowedRoles);
+};
+
+} // namespace smartbotic::microbit

+ 103 - 0
src/stores/assistant_store.cpp

@@ -0,0 +1,103 @@
+#include "assistant_store.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+// ===== Assistant serialization =====
+
+json Assistant::toJson() const {
+    return json{
+        {"id", id},
+        {"workspace_id", workspaceId},
+        {"callerai_assistant_id", calleraiAssistantId},
+        {"callerai_phone_number_id", calleraiPhoneNumberId},
+        {"name", name},
+        {"custom_config", customConfig},
+        {"voice_id", voiceId},
+        {"voice_provider_config_id", voiceProviderConfigId},
+        {"status", status},
+        {"created_at", createdAt},
+        {"updated_at", updatedAt}
+    };
+}
+
+Assistant Assistant::fromJson(const json& j) {
+    Assistant a;
+    a.id = j.value("id", "");
+    a.workspaceId = j.value("workspace_id", "");
+    a.calleraiAssistantId = j.value("callerai_assistant_id", "");
+    a.calleraiPhoneNumberId = j.value("callerai_phone_number_id", "");
+    a.name = j.value("name", "");
+    a.customConfig = j.value("custom_config", json::object());
+    a.voiceId = j.value("voice_id", "");
+    a.voiceProviderConfigId = j.value("voice_provider_config_id", "");
+    a.status = j.value("status", "active");
+    a.createdAt = j.value("created_at", uint64_t{0});
+    a.updatedAt = j.value("updated_at", uint64_t{0});
+    return a;
+}
+
+// ===== AssistantStore implementation =====
+
+AssistantStore::AssistantStore(database::Client* db)
+    : db_(db) {
+    if (!db_) {
+        throw std::runtime_error("AssistantStore: database client is null");
+    }
+}
+
+std::string AssistantStore::createAssistant(const Assistant& assistant) {
+    Assistant a = assistant;
+    uint64_t now = utils::nowUnixSeconds();
+
+    if (a.id.empty()) {
+        a.id = utils::generateId("ast_");
+    }
+    if (a.createdAt == 0) {
+        a.createdAt = now;
+    }
+    if (a.updatedAt == 0) {
+        a.updatedAt = now;
+    }
+
+    json data = a.toJson();
+    return db_->insert(ASSISTANTS_COLLECTION, data, a.id);
+}
+
+std::optional<Assistant> AssistantStore::getAssistant(const std::string& id) {
+    auto doc = db_->get(ASSISTANTS_COLLECTION, id);
+    if (!doc.has_value()) {
+        return std::nullopt;
+    }
+    return Assistant::fromJson(doc.value());
+}
+
+std::vector<Assistant> AssistantStore::listByWorkspace(const std::string& wsId) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"workspace_id", wsId}};
+    opts.sortField = "created_at";
+    opts.sortDescending = true;
+
+    auto docs = db_->find(ASSISTANTS_COLLECTION, opts);
+    std::vector<Assistant> assistants;
+    assistants.reserve(docs.size());
+    for (const auto& doc : docs) {
+        assistants.push_back(Assistant::fromJson(doc));
+    }
+    return assistants;
+}
+
+bool AssistantStore::updateAssistant(const std::string& id, const json& fields) {
+    json data = fields;
+    data["updated_at"] = utils::nowUnixSeconds();
+    return db_->update(ASSISTANTS_COLLECTION, id, data);
+}
+
+bool AssistantStore::deleteAssistant(const std::string& id) {
+    return db_->remove(ASSISTANTS_COLLECTION, id);
+}
+
+} // namespace smartbotic::microbit

+ 48 - 0
src/stores/assistant_store.hpp

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <smartbotic/database/client.hpp>
+#include <nlohmann/json.hpp>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+struct Assistant {
+    std::string id;
+    std::string workspaceId;
+    std::string calleraiAssistantId;
+    std::string calleraiPhoneNumberId;
+    std::string name;
+    json customConfig = json::object();
+    std::string voiceId;
+    std::string voiceProviderConfigId;
+    std::string status = "active";  // "active", "inactive"
+    uint64_t createdAt = 0;
+    uint64_t updatedAt = 0;
+
+    json toJson() const;
+    static Assistant fromJson(const json& j);
+};
+
+class AssistantStore {
+public:
+    explicit AssistantStore(database::Client* db);
+
+    std::string createAssistant(const Assistant& assistant);
+    std::optional<Assistant> getAssistant(const std::string& id);
+    std::vector<Assistant> listByWorkspace(const std::string& wsId);
+    bool updateAssistant(const std::string& id, const json& fields);
+    bool deleteAssistant(const std::string& id);
+
+private:
+    database::Client* db_;
+
+    static constexpr const char* ASSISTANTS_COLLECTION = "assistants";
+};
+
+} // namespace smartbotic::microbit

+ 260 - 0
src/stores/calendar_store.cpp

@@ -0,0 +1,260 @@
+#include "calendar_store.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+// ===== Calendar serialization =====
+
+json Calendar::toJson() const {
+    return json{
+        {"id", id},
+        {"workspace_id", workspaceId},
+        {"name", name},
+        {"timezone", timezone},
+        {"created_at", createdAt}
+    };
+}
+
+Calendar Calendar::fromJson(const json& j) {
+    Calendar c;
+    c.id = j.value("id", "");
+    c.workspaceId = j.value("workspace_id", "");
+    c.name = j.value("name", "");
+    c.timezone = j.value("timezone", "");
+    c.createdAt = j.value("created_at", uint64_t{0});
+    return c;
+}
+
+// ===== TimeSlot serialization =====
+
+json TimeSlot::toJson() const {
+    return json{
+        {"id", id},
+        {"calendar_id", calendarId},
+        {"workspace_id", workspaceId},
+        {"day_of_week", dayOfWeek},
+        {"start_time", startTime},
+        {"end_time", endTime},
+        {"is_active", isActive}
+    };
+}
+
+TimeSlot TimeSlot::fromJson(const json& j) {
+    TimeSlot ts;
+    ts.id = j.value("id", "");
+    ts.calendarId = j.value("calendar_id", "");
+    ts.workspaceId = j.value("workspace_id", "");
+    ts.dayOfWeek = j.value("day_of_week", 0);
+    ts.startTime = j.value("start_time", "");
+    ts.endTime = j.value("end_time", "");
+    ts.isActive = j.value("is_active", true);
+    return ts;
+}
+
+// ===== Appointment serialization =====
+
+json Appointment::toJson() const {
+    return json{
+        {"id", id},
+        {"calendar_id", calendarId},
+        {"workspace_id", workspaceId},
+        {"time_slot_id", timeSlotId},
+        {"date", date},
+        {"caller_name", callerName},
+        {"caller_phone", callerPhone},
+        {"notes", notes},
+        {"status", status},
+        {"created_at", createdAt}
+    };
+}
+
+Appointment Appointment::fromJson(const json& j) {
+    Appointment a;
+    a.id = j.value("id", "");
+    a.calendarId = j.value("calendar_id", "");
+    a.workspaceId = j.value("workspace_id", "");
+    a.timeSlotId = j.value("time_slot_id", "");
+    a.date = j.value("date", "");
+    a.callerName = j.value("caller_name", "");
+    a.callerPhone = j.value("caller_phone", "");
+    a.notes = j.value("notes", "");
+    a.status = j.value("status", "booked");
+    a.createdAt = j.value("created_at", uint64_t{0});
+    return a;
+}
+
+// ===== CalendarStore implementation =====
+
+CalendarStore::CalendarStore(database::Client* db)
+    : db_(db) {
+    if (!db_) {
+        throw std::runtime_error("CalendarStore: database client is null");
+    }
+}
+
+// ----- Calendar CRUD -----
+
+std::string CalendarStore::createCalendar(const Calendar& calendar) {
+    Calendar c = calendar;
+
+    if (c.id.empty()) {
+        c.id = utils::generateId("cal_");
+    }
+    if (c.createdAt == 0) {
+        c.createdAt = utils::nowUnixSeconds();
+    }
+
+    json data = c.toJson();
+    return db_->insert(CALENDARS_COLLECTION, data, c.id);
+}
+
+std::optional<Calendar> CalendarStore::getCalendar(const std::string& id) {
+    auto doc = db_->get(CALENDARS_COLLECTION, id);
+    if (!doc.has_value()) {
+        return std::nullopt;
+    }
+    return Calendar::fromJson(doc.value());
+}
+
+std::vector<Calendar> CalendarStore::listByWorkspace(const std::string& wsId) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"workspace_id", wsId}};
+
+    auto docs = db_->find(CALENDARS_COLLECTION, opts);
+    std::vector<Calendar> calendars;
+    calendars.reserve(docs.size());
+    for (const auto& doc : docs) {
+        calendars.push_back(Calendar::fromJson(doc));
+    }
+    return calendars;
+}
+
+bool CalendarStore::updateCalendar(const std::string& id, const json& fields) {
+    return db_->update(CALENDARS_COLLECTION, id, fields);
+}
+
+bool CalendarStore::deleteCalendar(const std::string& id) {
+    return db_->remove(CALENDARS_COLLECTION, id);
+}
+
+// ----- TimeSlot CRUD -----
+
+std::string CalendarStore::createTimeSlot(const TimeSlot& slot) {
+    TimeSlot ts = slot;
+
+    if (ts.id.empty()) {
+        ts.id = utils::generateId("ts_");
+    }
+
+    json data = ts.toJson();
+    return db_->insert(TIMESLOTS_COLLECTION, data, ts.id);
+}
+
+std::optional<TimeSlot> CalendarStore::getTimeSlot(const std::string& id) {
+    auto doc = db_->get(TIMESLOTS_COLLECTION, id);
+    if (!doc.has_value()) {
+        return std::nullopt;
+    }
+    return TimeSlot::fromJson(doc.value());
+}
+
+std::vector<TimeSlot> CalendarStore::listSlotsByCalendar(const std::string& calId) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"calendar_id", calId}};
+    opts.sortField = "day_of_week";
+
+    auto docs = db_->find(TIMESLOTS_COLLECTION, opts);
+    std::vector<TimeSlot> slots;
+    slots.reserve(docs.size());
+    for (const auto& doc : docs) {
+        slots.push_back(TimeSlot::fromJson(doc));
+    }
+    return slots;
+}
+
+bool CalendarStore::updateTimeSlot(const std::string& id, const json& fields) {
+    return db_->update(TIMESLOTS_COLLECTION, id, fields);
+}
+
+bool CalendarStore::deleteTimeSlot(const std::string& id) {
+    return db_->remove(TIMESLOTS_COLLECTION, id);
+}
+
+// ----- Appointment CRUD -----
+
+std::string CalendarStore::createAppointment(const Appointment& appointment) {
+    Appointment a = appointment;
+
+    if (a.id.empty()) {
+        a.id = utils::generateId("apt_");
+    }
+    if (a.createdAt == 0) {
+        a.createdAt = utils::nowUnixSeconds();
+    }
+
+    json data = a.toJson();
+    return db_->insert(APPOINTMENTS_COLLECTION, data, a.id);
+}
+
+std::optional<Appointment> CalendarStore::getAppointment(const std::string& id) {
+    auto doc = db_->get(APPOINTMENTS_COLLECTION, id);
+    if (!doc.has_value()) {
+        return std::nullopt;
+    }
+    return Appointment::fromJson(doc.value());
+}
+
+std::vector<Appointment> CalendarStore::listAppointmentsByWorkspace(
+    const std::string& wsId,
+    const std::string& dateFrom,
+    const std::string& dateTo) {
+
+    database::Client::QueryOptions opts;
+    opts.filters = {{"workspace_id", wsId}};
+    opts.sortField = "date";
+
+    auto docs = db_->find(APPOINTMENTS_COLLECTION, opts);
+    std::vector<Appointment> appointments;
+    appointments.reserve(docs.size());
+
+    for (const auto& doc : docs) {
+        Appointment apt = Appointment::fromJson(doc);
+
+        // Apply date range filtering in application layer
+        if (!dateFrom.empty() && apt.date < dateFrom) {
+            continue;
+        }
+        if (!dateTo.empty() && apt.date > dateTo) {
+            continue;
+        }
+
+        appointments.push_back(apt);
+    }
+
+    return appointments;
+}
+
+std::vector<Appointment> CalendarStore::listAppointmentsByCalendarAndDate(
+    const std::string& calId,
+    const std::string& date) {
+
+    database::Client::QueryOptions opts;
+    opts.filters = {{"calendar_id", calId}, {"date", date}};
+
+    auto docs = db_->find(APPOINTMENTS_COLLECTION, opts);
+    std::vector<Appointment> appointments;
+    appointments.reserve(docs.size());
+    for (const auto& doc : docs) {
+        appointments.push_back(Appointment::fromJson(doc));
+    }
+    return appointments;
+}
+
+bool CalendarStore::updateAppointment(const std::string& id, const json& fields) {
+    return db_->update(APPOINTMENTS_COLLECTION, id, fields);
+}
+
+} // namespace smartbotic::microbit

+ 93 - 0
src/stores/calendar_store.hpp

@@ -0,0 +1,93 @@
+#pragma once
+
+#include <smartbotic/database/client.hpp>
+#include <nlohmann/json.hpp>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+struct Calendar {
+    std::string id;
+    std::string workspaceId;
+    std::string name;
+    std::string timezone;
+    uint64_t createdAt = 0;
+
+    json toJson() const;
+    static Calendar fromJson(const json& j);
+};
+
+struct TimeSlot {
+    std::string id;
+    std::string calendarId;
+    std::string workspaceId;
+    int dayOfWeek = 0;  // 0-6 (Sunday-Saturday)
+    std::string startTime;  // "HH:MM"
+    std::string endTime;    // "HH:MM"
+    bool isActive = true;
+
+    json toJson() const;
+    static TimeSlot fromJson(const json& j);
+};
+
+struct Appointment {
+    std::string id;
+    std::string calendarId;
+    std::string workspaceId;
+    std::string timeSlotId;
+    std::string date;        // "YYYY-MM-DD"
+    std::string callerName;
+    std::string callerPhone;
+    std::string notes;
+    std::string status = "booked";  // "booked", "completed", "cancelled"
+    uint64_t createdAt = 0;
+
+    json toJson() const;
+    static Appointment fromJson(const json& j);
+};
+
+class CalendarStore {
+public:
+    explicit CalendarStore(database::Client* db);
+
+    // Calendar CRUD
+    std::string createCalendar(const Calendar& calendar);
+    std::optional<Calendar> getCalendar(const std::string& id);
+    std::vector<Calendar> listByWorkspace(const std::string& wsId);
+    bool updateCalendar(const std::string& id, const json& fields);
+    bool deleteCalendar(const std::string& id);
+
+    // TimeSlot CRUD
+    std::string createTimeSlot(const TimeSlot& slot);
+    std::optional<TimeSlot> getTimeSlot(const std::string& id);
+    std::vector<TimeSlot> listSlotsByCalendar(const std::string& calId);
+    bool updateTimeSlot(const std::string& id, const json& fields);
+    bool deleteTimeSlot(const std::string& id);
+
+    // Appointment CRUD
+    std::string createAppointment(const Appointment& appointment);
+    std::optional<Appointment> getAppointment(const std::string& id);
+    std::vector<Appointment> listAppointmentsByWorkspace(
+        const std::string& wsId,
+        const std::string& dateFrom = "",
+        const std::string& dateTo = "");
+    std::vector<Appointment> listAppointmentsByCalendarAndDate(
+        const std::string& calId,
+        const std::string& date);
+    bool updateAppointment(const std::string& id, const json& fields);
+
+private:
+    database::Client* db_;
+
+    static constexpr const char* CALENDARS_COLLECTION = "calendars";
+    static constexpr const char* TIMESLOTS_COLLECTION = "time_slots";
+    static constexpr const char* APPOINTMENTS_COLLECTION = "appointments";
+};
+
+} // namespace smartbotic::microbit

+ 119 - 0
src/stores/invitation_store.cpp

@@ -0,0 +1,119 @@
+#include "invitation_store.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+// ===== Invitation serialization =====
+
+json Invitation::toJson() const {
+    return json{
+        {"id", id},
+        {"workspace_id", workspaceId},
+        {"email", email},
+        {"token", token},
+        {"status", status},
+        {"role", role},
+        {"invited_by", invitedBy},
+        {"created_at", createdAt},
+        {"expires_at", expiresAt}
+    };
+}
+
+Invitation Invitation::fromJson(const json& j) {
+    Invitation inv;
+    inv.id = j.value("id", "");
+    inv.workspaceId = j.value("workspace_id", "");
+    inv.email = j.value("email", "");
+    inv.token = j.value("token", "");
+    inv.status = j.value("status", "pending");
+    inv.role = j.value("role", "");
+    inv.invitedBy = j.value("invited_by", "");
+    inv.createdAt = j.value("created_at", uint64_t{0});
+    inv.expiresAt = j.value("expires_at", uint64_t{0});
+    return inv;
+}
+
+// ===== InvitationStore implementation =====
+
+InvitationStore::InvitationStore(database::Client* db)
+    : db_(db) {
+    if (!db_) {
+        throw std::runtime_error("InvitationStore: database client is null");
+    }
+}
+
+std::string InvitationStore::createInvitation(const Invitation& invitation) {
+    Invitation inv = invitation;
+
+    if (inv.id.empty()) {
+        inv.id = utils::generateId("inv_");
+    }
+    if (inv.createdAt == 0) {
+        inv.createdAt = utils::nowUnixSeconds();
+    }
+
+    json data = inv.toJson();
+    return db_->insert(INVITATIONS_COLLECTION, data, inv.id);
+}
+
+std::optional<Invitation> InvitationStore::getInvitation(const std::string& id) {
+    auto doc = db_->get(INVITATIONS_COLLECTION, id);
+    if (!doc.has_value()) {
+        return std::nullopt;
+    }
+    return Invitation::fromJson(doc.value());
+}
+
+std::optional<Invitation> InvitationStore::getInvitationByToken(const std::string& token) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"token", token}};
+    opts.limit = 1;
+
+    auto results = db_->find(INVITATIONS_COLLECTION, opts);
+    if (results.empty()) {
+        return std::nullopt;
+    }
+    return Invitation::fromJson(results[0]);
+}
+
+std::vector<Invitation> InvitationStore::listWorkspaceInvitations(const std::string& wsId) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"workspace_id", wsId}};
+    opts.sortField = "created_at";
+    opts.sortDescending = true;
+
+    auto docs = db_->find(INVITATIONS_COLLECTION, opts);
+    std::vector<Invitation> invitations;
+    invitations.reserve(docs.size());
+    for (const auto& doc : docs) {
+        invitations.push_back(Invitation::fromJson(doc));
+    }
+    return invitations;
+}
+
+std::vector<Invitation> InvitationStore::listPendingByEmail(const std::string& email) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"email", email}, {"status", "pending"}};
+
+    auto docs = db_->find(INVITATIONS_COLLECTION, opts);
+    std::vector<Invitation> invitations;
+    invitations.reserve(docs.size());
+    for (const auto& doc : docs) {
+        invitations.push_back(Invitation::fromJson(doc));
+    }
+    return invitations;
+}
+
+bool InvitationStore::updateStatus(const std::string& id, const std::string& status) {
+    json data = {{"status", status}};
+    return db_->update(INVITATIONS_COLLECTION, id, data);
+}
+
+bool InvitationStore::deleteInvitation(const std::string& id) {
+    return db_->remove(INVITATIONS_COLLECTION, id);
+}
+
+} // namespace smartbotic::microbit

+ 48 - 0
src/stores/invitation_store.hpp

@@ -0,0 +1,48 @@
+#pragma once
+
+#include <smartbotic/database/client.hpp>
+#include <nlohmann/json.hpp>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+struct Invitation {
+    std::string id;
+    std::string workspaceId;
+    std::string email;
+    std::string token;
+    std::string status = "pending";  // "pending", "accepted", "declined"
+    std::string role;
+    std::string invitedBy;
+    uint64_t createdAt = 0;
+    uint64_t expiresAt = 0;
+
+    json toJson() const;
+    static Invitation fromJson(const json& j);
+};
+
+class InvitationStore {
+public:
+    explicit InvitationStore(database::Client* db);
+
+    std::string createInvitation(const Invitation& invitation);
+    std::optional<Invitation> getInvitation(const std::string& id);
+    std::optional<Invitation> getInvitationByToken(const std::string& token);
+    std::vector<Invitation> listWorkspaceInvitations(const std::string& wsId);
+    std::vector<Invitation> listPendingByEmail(const std::string& email);
+    bool updateStatus(const std::string& id, const std::string& status);
+    bool deleteInvitation(const std::string& id);
+
+private:
+    database::Client* db_;
+
+    static constexpr const char* INVITATIONS_COLLECTION = "invitations";
+};
+
+} // namespace smartbotic::microbit

+ 27 - 0
src/stores/settings_store.cpp

@@ -0,0 +1,27 @@
+#include "settings_store.hpp"
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+SettingsStore::SettingsStore(database::Client* db)
+    : db_(db) {
+    if (!db_) {
+        throw std::runtime_error("SettingsStore: database client is null");
+    }
+}
+
+std::optional<json> SettingsStore::getGlobalSettings() {
+    return db_->get(SETTINGS_COLLECTION, GLOBAL_SETTINGS_ID);
+}
+
+bool SettingsStore::updateGlobalSettings(const json& settings) {
+    if (db_->exists(SETTINGS_COLLECTION, GLOBAL_SETTINGS_ID)) {
+        return db_->update(SETTINGS_COLLECTION, GLOBAL_SETTINGS_ID, settings);
+    }
+    // Create the global settings document if it does not exist
+    std::string id = db_->insert(SETTINGS_COLLECTION, settings, GLOBAL_SETTINGS_ID);
+    return !id.empty();
+}
+
+} // namespace smartbotic::microbit

+ 27 - 0
src/stores/settings_store.hpp

@@ -0,0 +1,27 @@
+#pragma once
+
+#include <smartbotic/database/client.hpp>
+#include <nlohmann/json.hpp>
+
+#include <optional>
+#include <string>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+class SettingsStore {
+public:
+    explicit SettingsStore(database::Client* db);
+
+    std::optional<json> getGlobalSettings();
+    bool updateGlobalSettings(const json& settings);
+
+private:
+    database::Client* db_;
+
+    static constexpr const char* SETTINGS_COLLECTION = "settings";
+    static constexpr const char* GLOBAL_SETTINGS_ID = "global";
+};
+
+} // namespace smartbotic::microbit

+ 186 - 0
src/stores/user_store.cpp

@@ -0,0 +1,186 @@
+#include "user_store.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+// ===== User serialization =====
+
+json User::toJson() const {
+    return json{
+        {"id", id},
+        {"email", email},
+        {"password_hash", passwordHash},
+        {"display_name", displayName},
+        {"avatar_url", avatarUrl},
+        {"status", status},
+        {"created_at", createdAt},
+        {"updated_at", updatedAt}
+    };
+}
+
+User User::fromJson(const json& j) {
+    User u;
+    u.id = j.value("id", "");
+    u.email = j.value("email", "");
+    u.passwordHash = j.value("password_hash", "");
+    u.displayName = j.value("display_name", "");
+    u.avatarUrl = j.value("avatar_url", "");
+    u.status = j.value("status", "active");
+    u.createdAt = j.value("created_at", uint64_t{0});
+    u.updatedAt = j.value("updated_at", uint64_t{0});
+    return u;
+}
+
+// ===== Session serialization =====
+
+json Session::toJson() const {
+    return json{
+        {"id", id},
+        {"user_id", userId},
+        {"refresh_token_hash", refreshTokenHash},
+        {"device_info", deviceInfo},
+        {"created_at", createdAt},
+        {"expires_at", expiresAt}
+    };
+}
+
+Session Session::fromJson(const json& j) {
+    Session s;
+    s.id = j.value("id", "");
+    s.userId = j.value("user_id", "");
+    s.refreshTokenHash = j.value("refresh_token_hash", "");
+    s.deviceInfo = j.value("device_info", "");
+    s.createdAt = j.value("created_at", uint64_t{0});
+    s.expiresAt = j.value("expires_at", uint64_t{0});
+    return s;
+}
+
+// ===== UserStore implementation =====
+
+UserStore::UserStore(database::Client* db)
+    : db_(db) {
+    if (!db_) {
+        throw std::runtime_error("UserStore: database client is null");
+    }
+}
+
+bool UserStore::hasAnyUsers() {
+    return db_->count(USERS_COLLECTION) > 0;
+}
+
+std::string UserStore::createUser(const User& user) {
+    User u = user;
+    uint64_t now = utils::nowUnixSeconds();
+
+    if (u.id.empty()) {
+        u.id = utils::generateId("usr_");
+    }
+    if (u.createdAt == 0) {
+        u.createdAt = now;
+    }
+    if (u.updatedAt == 0) {
+        u.updatedAt = now;
+    }
+
+    json data = u.toJson();
+    return db_->insert(USERS_COLLECTION, data, u.id);
+}
+
+std::optional<User> UserStore::getUser(const std::string& id) {
+    auto doc = db_->get(USERS_COLLECTION, id);
+    if (!doc.has_value()) {
+        return std::nullopt;
+    }
+    return User::fromJson(doc.value());
+}
+
+std::optional<User> UserStore::getUserByEmail(const std::string& email) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"email", email}};
+    opts.limit = 1;
+
+    auto results = db_->find(USERS_COLLECTION, opts);
+    if (results.empty()) {
+        return std::nullopt;
+    }
+    return User::fromJson(results[0]);
+}
+
+bool UserStore::updateUser(const std::string& id, const json& fields) {
+    json data = fields;
+    data["updated_at"] = utils::nowUnixSeconds();
+    return db_->update(USERS_COLLECTION, id, data);
+}
+
+bool UserStore::deleteUser(const std::string& id) {
+    return db_->remove(USERS_COLLECTION, id);
+}
+
+std::vector<User> UserStore::listUsers() {
+    auto docs = db_->find(USERS_COLLECTION);
+    std::vector<User> users;
+    users.reserve(docs.size());
+    for (const auto& doc : docs) {
+        users.push_back(User::fromJson(doc));
+    }
+    return users;
+}
+
+std::string UserStore::createSession(const Session& session) {
+    Session s = session;
+    uint64_t now = utils::nowUnixSeconds();
+
+    if (s.id.empty()) {
+        s.id = utils::generateId("ses_");
+    }
+    if (s.createdAt == 0) {
+        s.createdAt = now;
+    }
+
+    json data = s.toJson();
+    return db_->insert(SESSIONS_COLLECTION, data, s.id);
+}
+
+std::optional<Session> UserStore::getSession(const std::string& id) {
+    auto doc = db_->get(SESSIONS_COLLECTION, id);
+    if (!doc.has_value()) {
+        return std::nullopt;
+    }
+    return Session::fromJson(doc.value());
+}
+
+std::optional<Session> UserStore::getSessionByUserId(const std::string& userId) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"user_id", userId}};
+    opts.limit = 1;
+    opts.sortField = "created_at";
+    opts.sortDescending = true;
+
+    auto results = db_->find(SESSIONS_COLLECTION, opts);
+    if (results.empty()) {
+        return std::nullopt;
+    }
+    return Session::fromJson(results[0]);
+}
+
+bool UserStore::deleteSession(const std::string& id) {
+    return db_->remove(SESSIONS_COLLECTION, id);
+}
+
+void UserStore::deleteUserSessions(const std::string& userId) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"user_id", userId}};
+
+    auto results = db_->find(SESSIONS_COLLECTION, opts);
+    for (const auto& doc : results) {
+        std::string sessionId = doc.value("id", "");
+        if (!sessionId.empty()) {
+            db_->remove(SESSIONS_COLLECTION, sessionId);
+        }
+    }
+}
+
+} // namespace smartbotic::microbit

+ 66 - 0
src/stores/user_store.hpp

@@ -0,0 +1,66 @@
+#pragma once
+
+#include <smartbotic/database/client.hpp>
+#include <nlohmann/json.hpp>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+struct User {
+    std::string id;
+    std::string email;
+    std::string passwordHash;
+    std::string displayName;
+    std::string avatarUrl;
+    std::string status = "active";
+    uint64_t createdAt = 0;
+    uint64_t updatedAt = 0;
+
+    json toJson() const;
+    static User fromJson(const json& j);
+};
+
+struct Session {
+    std::string id;
+    std::string userId;
+    std::string refreshTokenHash;
+    std::string deviceInfo;
+    uint64_t createdAt = 0;
+    uint64_t expiresAt = 0;
+
+    json toJson() const;
+    static Session fromJson(const json& j);
+};
+
+class UserStore {
+public:
+    explicit UserStore(database::Client* db);
+
+    bool hasAnyUsers();
+    std::string createUser(const User& user);
+    std::optional<User> getUser(const std::string& id);
+    std::optional<User> getUserByEmail(const std::string& email);
+    bool updateUser(const std::string& id, const json& fields);
+    bool deleteUser(const std::string& id);
+    std::vector<User> listUsers();
+
+    std::string createSession(const Session& session);
+    std::optional<Session> getSession(const std::string& id);
+    std::optional<Session> getSessionByUserId(const std::string& userId);
+    bool deleteSession(const std::string& id);
+    void deleteUserSessions(const std::string& userId);
+
+private:
+    database::Client* db_;
+
+    static constexpr const char* USERS_COLLECTION = "users";
+    static constexpr const char* SESSIONS_COLLECTION = "sessions";
+};
+
+} // namespace smartbotic::microbit

+ 179 - 0
src/stores/workspace_store.cpp

@@ -0,0 +1,179 @@
+#include "workspace_store.hpp"
+
+#include <smartbotic/microbit/common/utils.hpp>
+
+#include <stdexcept>
+
+namespace smartbotic::microbit {
+
+// ===== Workspace serialization =====
+
+json Workspace::toJson() const {
+    return json{
+        {"id", id},
+        {"name", name},
+        {"company_name", companyName},
+        {"address", address},
+        {"phone", phone},
+        {"settings", settings},
+        {"created_at", createdAt},
+        {"updated_at", updatedAt}
+    };
+}
+
+Workspace Workspace::fromJson(const json& j) {
+    Workspace w;
+    w.id = j.value("id", "");
+    w.name = j.value("name", "");
+    w.companyName = j.value("company_name", "");
+    w.address = j.value("address", "");
+    w.phone = j.value("phone", "");
+    w.settings = j.value("settings", json::object());
+    w.createdAt = j.value("created_at", uint64_t{0});
+    w.updatedAt = j.value("updated_at", uint64_t{0});
+    return w;
+}
+
+// ===== WorkspaceMember serialization =====
+
+json WorkspaceMember::toJson() const {
+    return json{
+        {"id", id},
+        {"workspace_id", workspaceId},
+        {"user_id", userId},
+        {"role", role},
+        {"joined_at", joinedAt},
+        {"invited_by", invitedBy}
+    };
+}
+
+WorkspaceMember WorkspaceMember::fromJson(const json& j) {
+    WorkspaceMember m;
+    m.id = j.value("id", "");
+    m.workspaceId = j.value("workspace_id", "");
+    m.userId = j.value("user_id", "");
+    m.role = j.value("role", "member");
+    m.joinedAt = j.value("joined_at", uint64_t{0});
+    m.invitedBy = j.value("invited_by", "");
+    return m;
+}
+
+// ===== WorkspaceStore implementation =====
+
+WorkspaceStore::WorkspaceStore(database::Client* db)
+    : db_(db) {
+    if (!db_) {
+        throw std::runtime_error("WorkspaceStore: database client is null");
+    }
+}
+
+std::string WorkspaceStore::createWorkspace(const Workspace& workspace) {
+    Workspace w = workspace;
+    uint64_t now = utils::nowUnixSeconds();
+
+    if (w.id.empty()) {
+        w.id = utils::generateId("ws_");
+    }
+    if (w.createdAt == 0) {
+        w.createdAt = now;
+    }
+    if (w.updatedAt == 0) {
+        w.updatedAt = now;
+    }
+
+    json data = w.toJson();
+    return db_->insert(WORKSPACES_COLLECTION, data, w.id);
+}
+
+std::optional<Workspace> WorkspaceStore::getWorkspace(const std::string& id) {
+    auto doc = db_->get(WORKSPACES_COLLECTION, id);
+    if (!doc.has_value()) {
+        return std::nullopt;
+    }
+    return Workspace::fromJson(doc.value());
+}
+
+bool WorkspaceStore::updateWorkspace(const std::string& id, const json& fields) {
+    json data = fields;
+    data["updated_at"] = utils::nowUnixSeconds();
+    return db_->update(WORKSPACES_COLLECTION, id, data);
+}
+
+bool WorkspaceStore::deleteWorkspace(const std::string& id) {
+    return db_->remove(WORKSPACES_COLLECTION, id);
+}
+
+std::string WorkspaceStore::addMember(const WorkspaceMember& member) {
+    WorkspaceMember m = member;
+
+    if (m.id.empty()) {
+        m.id = utils::generateId("wm_");
+    }
+    if (m.joinedAt == 0) {
+        m.joinedAt = utils::nowUnixSeconds();
+    }
+
+    json data = m.toJson();
+    return db_->insert(MEMBERS_COLLECTION, data, m.id);
+}
+
+std::optional<WorkspaceMember> WorkspaceStore::getMember(
+    const std::string& wsId, const std::string& userId) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"workspace_id", wsId}, {"user_id", userId}};
+    opts.limit = 1;
+
+    auto results = db_->find(MEMBERS_COLLECTION, opts);
+    if (results.empty()) {
+        return std::nullopt;
+    }
+    return WorkspaceMember::fromJson(results[0]);
+}
+
+std::vector<WorkspaceMember> WorkspaceStore::listMembers(const std::string& wsId) {
+    database::Client::QueryOptions opts;
+    opts.filters = {{"workspace_id", wsId}};
+
+    auto docs = db_->find(MEMBERS_COLLECTION, opts);
+    std::vector<WorkspaceMember> members;
+    members.reserve(docs.size());
+    for (const auto& doc : docs) {
+        members.push_back(WorkspaceMember::fromJson(doc));
+    }
+    return members;
+}
+
+std::vector<Workspace> WorkspaceStore::listUserWorkspaces(const std::string& userId) {
+    // First find all memberships for this user
+    database::Client::QueryOptions opts;
+    opts.filters = {{"user_id", userId}};
+
+    auto memberDocs = db_->find(MEMBERS_COLLECTION, opts);
+
+    std::vector<Workspace> workspaces;
+    workspaces.reserve(memberDocs.size());
+
+    for (const auto& memberDoc : memberDocs) {
+        std::string wsId = memberDoc.value("workspace_id", "");
+        if (wsId.empty()) {
+            continue;
+        }
+        auto ws = getWorkspace(wsId);
+        if (ws.has_value()) {
+            workspaces.push_back(ws.value());
+        }
+    }
+
+    return workspaces;
+}
+
+bool WorkspaceStore::updateMemberRole(const std::string& memberId, const std::string& newRole) {
+    json data = {{"role", newRole}};
+    return db_->update(MEMBERS_COLLECTION, memberId, data);
+}
+
+bool WorkspaceStore::removeMember(const std::string& memberId) {
+    return db_->remove(MEMBERS_COLLECTION, memberId);
+}
+
+} // namespace smartbotic::microbit

+ 64 - 0
src/stores/workspace_store.hpp

@@ -0,0 +1,64 @@
+#pragma once
+
+#include <smartbotic/database/client.hpp>
+#include <nlohmann/json.hpp>
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace smartbotic::microbit {
+
+using json = nlohmann::json;
+
+struct Workspace {
+    std::string id;
+    std::string name;
+    std::string companyName;
+    std::string address;
+    std::string phone;
+    json settings = json::object();
+    uint64_t createdAt = 0;
+    uint64_t updatedAt = 0;
+
+    json toJson() const;
+    static Workspace fromJson(const json& j);
+};
+
+struct WorkspaceMember {
+    std::string id;
+    std::string workspaceId;
+    std::string userId;
+    std::string role = "member";  // "owner", "admin", "member", "viewer"
+    uint64_t joinedAt = 0;
+    std::string invitedBy;
+
+    json toJson() const;
+    static WorkspaceMember fromJson(const json& j);
+};
+
+class WorkspaceStore {
+public:
+    explicit WorkspaceStore(database::Client* db);
+
+    std::string createWorkspace(const Workspace& workspace);
+    std::optional<Workspace> getWorkspace(const std::string& id);
+    bool updateWorkspace(const std::string& id, const json& fields);
+    bool deleteWorkspace(const std::string& id);
+
+    std::string addMember(const WorkspaceMember& member);
+    std::optional<WorkspaceMember> getMember(const std::string& wsId, const std::string& userId);
+    std::vector<WorkspaceMember> listMembers(const std::string& wsId);
+    std::vector<Workspace> listUserWorkspaces(const std::string& userId);
+    bool updateMemberRole(const std::string& memberId, const std::string& newRole);
+    bool removeMember(const std::string& memberId);
+
+private:
+    database::Client* db_;
+
+    static constexpr const char* WORKSPACES_COLLECTION = "workspaces";
+    static constexpr const char* MEMBERS_COLLECTION = "workspace_members";
+};
+
+} // namespace smartbotic::microbit

+ 12 - 0
webui/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <meta charset="UTF-8" />
+        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+        <title>Smartbotic Microbit</title>
+    </head>
+    <body>
+        <div id="root"></div>
+        <script type="module" src="/src/main.tsx"></script>
+    </body>
+</html>

+ 2464 - 0
webui/package-lock.json

@@ -0,0 +1,2464 @@
+{
+    "name": "smartbotic-microbit-webui",
+    "version": "0.1.0",
+    "lockfileVersion": 3,
+    "requires": true,
+    "packages": {
+        "": {
+            "name": "smartbotic-microbit-webui",
+            "version": "0.1.0",
+            "dependencies": {
+                "@tailwindcss/vite": "^4.1.18",
+                "@tanstack/react-query": "^5.90.16",
+                "clsx": "^2.1.1",
+                "lucide-react": "^0.562.0",
+                "react": "^19.2.0",
+                "react-dom": "^19.2.0",
+                "react-router-dom": "^7.11.0",
+                "tailwindcss": "^4.1.18",
+                "zustand": "^5.0.10"
+            },
+            "devDependencies": {
+                "@types/react": "^19.2.5",
+                "@types/react-dom": "^19.2.3",
+                "@vitejs/plugin-react": "^5.1.1",
+                "typescript": "~5.9.3",
+                "vite": "^7.2.4"
+            }
+        },
+        "node_modules/@babel/code-frame": {
+            "version": "7.29.0",
+            "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+            "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-validator-identifier": "^7.28.5",
+                "js-tokens": "^4.0.0",
+                "picocolors": "^1.1.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/compat-data": {
+            "version": "7.29.0",
+            "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+            "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/core": {
+            "version": "7.29.0",
+            "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+            "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+            "dev": true,
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "@babel/code-frame": "^7.29.0",
+                "@babel/generator": "^7.29.0",
+                "@babel/helper-compilation-targets": "^7.28.6",
+                "@babel/helper-module-transforms": "^7.28.6",
+                "@babel/helpers": "^7.28.6",
+                "@babel/parser": "^7.29.0",
+                "@babel/template": "^7.28.6",
+                "@babel/traverse": "^7.29.0",
+                "@babel/types": "^7.29.0",
+                "@jridgewell/remapping": "^2.3.5",
+                "convert-source-map": "^2.0.0",
+                "debug": "^4.1.0",
+                "gensync": "^1.0.0-beta.2",
+                "json5": "^2.2.3",
+                "semver": "^6.3.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/babel"
+            }
+        },
+        "node_modules/@babel/generator": {
+            "version": "7.29.1",
+            "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+            "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/parser": "^7.29.0",
+                "@babel/types": "^7.29.0",
+                "@jridgewell/gen-mapping": "^0.3.12",
+                "@jridgewell/trace-mapping": "^0.3.28",
+                "jsesc": "^3.0.2"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-compilation-targets": {
+            "version": "7.28.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+            "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/compat-data": "^7.28.6",
+                "@babel/helper-validator-option": "^7.27.1",
+                "browserslist": "^4.24.0",
+                "lru-cache": "^5.1.1",
+                "semver": "^6.3.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-globals": {
+            "version": "7.28.0",
+            "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+            "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-module-imports": {
+            "version": "7.28.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+            "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/traverse": "^7.28.6",
+                "@babel/types": "^7.28.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-module-transforms": {
+            "version": "7.28.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+            "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-module-imports": "^7.28.6",
+                "@babel/helper-validator-identifier": "^7.28.5",
+                "@babel/traverse": "^7.28.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0"
+            }
+        },
+        "node_modules/@babel/helper-plugin-utils": {
+            "version": "7.28.6",
+            "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+            "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-string-parser": {
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+            "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-validator-identifier": {
+            "version": "7.28.5",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+            "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helper-validator-option": {
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+            "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/helpers": {
+            "version": "7.28.6",
+            "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
+            "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/template": "^7.28.6",
+                "@babel/types": "^7.28.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/parser": {
+            "version": "7.29.0",
+            "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+            "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.29.0"
+            },
+            "bin": {
+                "parser": "bin/babel-parser.js"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-react-jsx-self": {
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
+            "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.27.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/plugin-transform-react-jsx-source": {
+            "version": "7.27.1",
+            "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
+            "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-plugin-utils": "^7.27.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            },
+            "peerDependencies": {
+                "@babel/core": "^7.0.0-0"
+            }
+        },
+        "node_modules/@babel/template": {
+            "version": "7.28.6",
+            "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+            "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/code-frame": "^7.28.6",
+                "@babel/parser": "^7.28.6",
+                "@babel/types": "^7.28.6"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/traverse": {
+            "version": "7.29.0",
+            "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+            "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/code-frame": "^7.29.0",
+                "@babel/generator": "^7.29.0",
+                "@babel/helper-globals": "^7.28.0",
+                "@babel/parser": "^7.29.0",
+                "@babel/template": "^7.28.6",
+                "@babel/types": "^7.29.0",
+                "debug": "^4.3.1"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@babel/types": {
+            "version": "7.29.0",
+            "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+            "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/helper-string-parser": "^7.27.1",
+                "@babel/helper-validator-identifier": "^7.28.5"
+            },
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/@esbuild/aix-ppc64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+            "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+            "cpu": [
+                "ppc64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "aix"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/android-arm": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+            "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+            "cpu": [
+                "arm"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/android-arm64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+            "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/android-x64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+            "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/darwin-arm64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+            "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/darwin-x64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+            "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/freebsd-arm64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+            "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/freebsd-x64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+            "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-arm": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+            "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+            "cpu": [
+                "arm"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-arm64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+            "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-ia32": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+            "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+            "cpu": [
+                "ia32"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-loong64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+            "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+            "cpu": [
+                "loong64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-mips64el": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+            "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+            "cpu": [
+                "mips64el"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-ppc64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+            "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+            "cpu": [
+                "ppc64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-riscv64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+            "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+            "cpu": [
+                "riscv64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-s390x": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+            "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+            "cpu": [
+                "s390x"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/linux-x64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+            "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/netbsd-arm64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+            "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "netbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/netbsd-x64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+            "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "netbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/openbsd-arm64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+            "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/openbsd-x64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+            "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openbsd"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/openharmony-arm64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+            "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openharmony"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/sunos-x64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+            "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "sunos"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/win32-arm64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+            "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/win32-ia32": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+            "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+            "cpu": [
+                "ia32"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@esbuild/win32-x64": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+            "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=18"
+            }
+        },
+        "node_modules/@jridgewell/gen-mapping": {
+            "version": "0.3.13",
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+            "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+            "license": "MIT",
+            "dependencies": {
+                "@jridgewell/sourcemap-codec": "^1.5.0",
+                "@jridgewell/trace-mapping": "^0.3.24"
+            }
+        },
+        "node_modules/@jridgewell/remapping": {
+            "version": "2.3.5",
+            "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+            "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+            "license": "MIT",
+            "dependencies": {
+                "@jridgewell/gen-mapping": "^0.3.5",
+                "@jridgewell/trace-mapping": "^0.3.24"
+            }
+        },
+        "node_modules/@jridgewell/resolve-uri": {
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+            "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
+        "node_modules/@jridgewell/sourcemap-codec": {
+            "version": "1.5.5",
+            "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+            "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+            "license": "MIT"
+        },
+        "node_modules/@jridgewell/trace-mapping": {
+            "version": "0.3.31",
+            "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+            "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+            "license": "MIT",
+            "dependencies": {
+                "@jridgewell/resolve-uri": "^3.1.0",
+                "@jridgewell/sourcemap-codec": "^1.4.14"
+            }
+        },
+        "node_modules/@rolldown/pluginutils": {
+            "version": "1.0.0-rc.2",
+            "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
+            "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/@rollup/rollup-android-arm-eabi": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
+            "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
+            "cpu": [
+                "arm"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ]
+        },
+        "node_modules/@rollup/rollup-android-arm64": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
+            "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ]
+        },
+        "node_modules/@rollup/rollup-darwin-arm64": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
+            "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ]
+        },
+        "node_modules/@rollup/rollup-darwin-x64": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
+            "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ]
+        },
+        "node_modules/@rollup/rollup-freebsd-arm64": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
+            "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ]
+        },
+        "node_modules/@rollup/rollup-freebsd-x64": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
+            "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
+            "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
+            "cpu": [
+                "arm"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
+            "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
+            "cpu": [
+                "arm"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-arm64-gnu": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
+            "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-arm64-musl": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
+            "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-loong64-gnu": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
+            "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
+            "cpu": [
+                "loong64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-loong64-musl": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
+            "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
+            "cpu": [
+                "loong64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
+            "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
+            "cpu": [
+                "ppc64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-ppc64-musl": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
+            "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
+            "cpu": [
+                "ppc64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
+            "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
+            "cpu": [
+                "riscv64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-riscv64-musl": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
+            "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
+            "cpu": [
+                "riscv64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-s390x-gnu": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
+            "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
+            "cpu": [
+                "s390x"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-x64-gnu": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
+            "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-linux-x64-musl": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
+            "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ]
+        },
+        "node_modules/@rollup/rollup-openbsd-x64": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
+            "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openbsd"
+            ]
+        },
+        "node_modules/@rollup/rollup-openharmony-arm64": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
+            "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "openharmony"
+            ]
+        },
+        "node_modules/@rollup/rollup-win32-arm64-msvc": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
+            "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
+        "node_modules/@rollup/rollup-win32-ia32-msvc": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
+            "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
+            "cpu": [
+                "ia32"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
+        "node_modules/@rollup/rollup-win32-x64-gnu": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
+            "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
+        "node_modules/@rollup/rollup-win32-x64-msvc": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
+            "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ]
+        },
+        "node_modules/@tailwindcss/node": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
+            "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+            "license": "MIT",
+            "dependencies": {
+                "@jridgewell/remapping": "^2.3.4",
+                "enhanced-resolve": "^5.18.3",
+                "jiti": "^2.6.1",
+                "lightningcss": "1.30.2",
+                "magic-string": "^0.30.21",
+                "source-map-js": "^1.2.1",
+                "tailwindcss": "4.1.18"
+            }
+        },
+        "node_modules/@tailwindcss/oxide": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+            "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+            "license": "MIT",
+            "engines": {
+                "node": ">= 10"
+            },
+            "optionalDependencies": {
+                "@tailwindcss/oxide-android-arm64": "4.1.18",
+                "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+                "@tailwindcss/oxide-darwin-x64": "4.1.18",
+                "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+                "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+                "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+                "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+                "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+                "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+                "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+                "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+                "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-android-arm64": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+            "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-darwin-arm64": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+            "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-darwin-x64": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+            "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-freebsd-x64": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+            "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+            "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+            "cpu": [
+                "arm"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+            "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+            "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+            "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+            "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+            "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+            "bundleDependencies": [
+                "@napi-rs/wasm-runtime",
+                "@emnapi/core",
+                "@emnapi/runtime",
+                "@tybys/wasm-util",
+                "@emnapi/wasi-threads",
+                "tslib"
+            ],
+            "cpu": [
+                "wasm32"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "dependencies": {
+                "@emnapi/core": "^1.7.1",
+                "@emnapi/runtime": "^1.7.1",
+                "@emnapi/wasi-threads": "^1.1.0",
+                "@napi-rs/wasm-runtime": "^1.1.0",
+                "@tybys/wasm-util": "^0.10.1",
+                "tslib": "^2.4.0"
+            },
+            "engines": {
+                "node": ">=14.0.0"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+            "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+            "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">= 10"
+            }
+        },
+        "node_modules/@tailwindcss/vite": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz",
+            "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
+            "license": "MIT",
+            "dependencies": {
+                "@tailwindcss/node": "4.1.18",
+                "@tailwindcss/oxide": "4.1.18",
+                "tailwindcss": "4.1.18"
+            },
+            "peerDependencies": {
+                "vite": "^5.2.0 || ^6 || ^7"
+            }
+        },
+        "node_modules/@tanstack/query-core": {
+            "version": "5.90.20",
+            "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
+            "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
+            "license": "MIT",
+            "funding": {
+                "type": "github",
+                "url": "https://github.com/sponsors/tannerlinsley"
+            }
+        },
+        "node_modules/@tanstack/react-query": {
+            "version": "5.90.20",
+            "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
+            "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
+            "license": "MIT",
+            "dependencies": {
+                "@tanstack/query-core": "5.90.20"
+            },
+            "funding": {
+                "type": "github",
+                "url": "https://github.com/sponsors/tannerlinsley"
+            },
+            "peerDependencies": {
+                "react": "^18 || ^19"
+            }
+        },
+        "node_modules/@types/babel__core": {
+            "version": "7.20.5",
+            "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+            "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/parser": "^7.20.7",
+                "@babel/types": "^7.20.7",
+                "@types/babel__generator": "*",
+                "@types/babel__template": "*",
+                "@types/babel__traverse": "*"
+            }
+        },
+        "node_modules/@types/babel__generator": {
+            "version": "7.27.0",
+            "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+            "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.0.0"
+            }
+        },
+        "node_modules/@types/babel__template": {
+            "version": "7.4.4",
+            "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+            "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/parser": "^7.1.0",
+                "@babel/types": "^7.0.0"
+            }
+        },
+        "node_modules/@types/babel__traverse": {
+            "version": "7.28.0",
+            "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+            "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/types": "^7.28.2"
+            }
+        },
+        "node_modules/@types/estree": {
+            "version": "1.0.8",
+            "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+            "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+            "license": "MIT"
+        },
+        "node_modules/@types/react": {
+            "version": "19.2.13",
+            "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
+            "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
+            "devOptional": true,
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "csstype": "^3.2.2"
+            }
+        },
+        "node_modules/@types/react-dom": {
+            "version": "19.2.3",
+            "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+            "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+            "dev": true,
+            "license": "MIT",
+            "peerDependencies": {
+                "@types/react": "^19.2.0"
+            }
+        },
+        "node_modules/@vitejs/plugin-react": {
+            "version": "5.1.3",
+            "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz",
+            "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==",
+            "dev": true,
+            "license": "MIT",
+            "dependencies": {
+                "@babel/core": "^7.29.0",
+                "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+                "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+                "@rolldown/pluginutils": "1.0.0-rc.2",
+                "@types/babel__core": "^7.20.5",
+                "react-refresh": "^0.18.0"
+            },
+            "engines": {
+                "node": "^20.19.0 || >=22.12.0"
+            },
+            "peerDependencies": {
+                "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+            }
+        },
+        "node_modules/baseline-browser-mapping": {
+            "version": "2.9.19",
+            "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
+            "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
+            "dev": true,
+            "license": "Apache-2.0",
+            "bin": {
+                "baseline-browser-mapping": "dist/cli.js"
+            }
+        },
+        "node_modules/browserslist": {
+            "version": "4.28.1",
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+            "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/browserslist"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/browserslist"
+                },
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/ai"
+                }
+            ],
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "baseline-browser-mapping": "^2.9.0",
+                "caniuse-lite": "^1.0.30001759",
+                "electron-to-chromium": "^1.5.263",
+                "node-releases": "^2.0.27",
+                "update-browserslist-db": "^1.2.0"
+            },
+            "bin": {
+                "browserslist": "cli.js"
+            },
+            "engines": {
+                "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+            }
+        },
+        "node_modules/caniuse-lite": {
+            "version": "1.0.30001769",
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
+            "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/browserslist"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+                },
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/ai"
+                }
+            ],
+            "license": "CC-BY-4.0"
+        },
+        "node_modules/clsx": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+            "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/convert-source-map": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+            "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/cookie": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+            "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+            "license": "MIT",
+            "engines": {
+                "node": ">=18"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/express"
+            }
+        },
+        "node_modules/csstype": {
+            "version": "3.2.3",
+            "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+            "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+            "devOptional": true,
+            "license": "MIT"
+        },
+        "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/detect-libc": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+            "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+            "license": "Apache-2.0",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/electron-to-chromium": {
+            "version": "1.5.286",
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
+            "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
+            "dev": true,
+            "license": "ISC"
+        },
+        "node_modules/enhanced-resolve": {
+            "version": "5.19.0",
+            "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
+            "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
+            "license": "MIT",
+            "dependencies": {
+                "graceful-fs": "^4.2.4",
+                "tapable": "^2.3.0"
+            },
+            "engines": {
+                "node": ">=10.13.0"
+            }
+        },
+        "node_modules/esbuild": {
+            "version": "0.27.3",
+            "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+            "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+            "hasInstallScript": true,
+            "license": "MIT",
+            "bin": {
+                "esbuild": "bin/esbuild"
+            },
+            "engines": {
+                "node": ">=18"
+            },
+            "optionalDependencies": {
+                "@esbuild/aix-ppc64": "0.27.3",
+                "@esbuild/android-arm": "0.27.3",
+                "@esbuild/android-arm64": "0.27.3",
+                "@esbuild/android-x64": "0.27.3",
+                "@esbuild/darwin-arm64": "0.27.3",
+                "@esbuild/darwin-x64": "0.27.3",
+                "@esbuild/freebsd-arm64": "0.27.3",
+                "@esbuild/freebsd-x64": "0.27.3",
+                "@esbuild/linux-arm": "0.27.3",
+                "@esbuild/linux-arm64": "0.27.3",
+                "@esbuild/linux-ia32": "0.27.3",
+                "@esbuild/linux-loong64": "0.27.3",
+                "@esbuild/linux-mips64el": "0.27.3",
+                "@esbuild/linux-ppc64": "0.27.3",
+                "@esbuild/linux-riscv64": "0.27.3",
+                "@esbuild/linux-s390x": "0.27.3",
+                "@esbuild/linux-x64": "0.27.3",
+                "@esbuild/netbsd-arm64": "0.27.3",
+                "@esbuild/netbsd-x64": "0.27.3",
+                "@esbuild/openbsd-arm64": "0.27.3",
+                "@esbuild/openbsd-x64": "0.27.3",
+                "@esbuild/openharmony-arm64": "0.27.3",
+                "@esbuild/sunos-x64": "0.27.3",
+                "@esbuild/win32-arm64": "0.27.3",
+                "@esbuild/win32-ia32": "0.27.3",
+                "@esbuild/win32-x64": "0.27.3"
+            }
+        },
+        "node_modules/escalade": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+            "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/fdir": {
+            "version": "6.5.0",
+            "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+            "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+            "license": "MIT",
+            "engines": {
+                "node": ">=12.0.0"
+            },
+            "peerDependencies": {
+                "picomatch": "^3 || ^4"
+            },
+            "peerDependenciesMeta": {
+                "picomatch": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/fsevents": {
+            "version": "2.3.3",
+            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+            "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+            "hasInstallScript": true,
+            "license": "MIT",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+            }
+        },
+        "node_modules/gensync": {
+            "version": "1.0.0-beta.2",
+            "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+            "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=6.9.0"
+            }
+        },
+        "node_modules/graceful-fs": {
+            "version": "4.2.11",
+            "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+            "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+            "license": "ISC"
+        },
+        "node_modules/jiti": {
+            "version": "2.6.1",
+            "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+            "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+            "license": "MIT",
+            "bin": {
+                "jiti": "lib/jiti-cli.mjs"
+            }
+        },
+        "node_modules/js-tokens": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+            "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/jsesc": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+            "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+            "dev": true,
+            "license": "MIT",
+            "bin": {
+                "jsesc": "bin/jsesc"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/json5": {
+            "version": "2.2.3",
+            "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+            "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+            "dev": true,
+            "license": "MIT",
+            "bin": {
+                "json5": "lib/cli.js"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/lightningcss": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
+            "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+            "license": "MPL-2.0",
+            "dependencies": {
+                "detect-libc": "^2.0.3"
+            },
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            },
+            "optionalDependencies": {
+                "lightningcss-android-arm64": "1.30.2",
+                "lightningcss-darwin-arm64": "1.30.2",
+                "lightningcss-darwin-x64": "1.30.2",
+                "lightningcss-freebsd-x64": "1.30.2",
+                "lightningcss-linux-arm-gnueabihf": "1.30.2",
+                "lightningcss-linux-arm64-gnu": "1.30.2",
+                "lightningcss-linux-arm64-musl": "1.30.2",
+                "lightningcss-linux-x64-gnu": "1.30.2",
+                "lightningcss-linux-x64-musl": "1.30.2",
+                "lightningcss-win32-arm64-msvc": "1.30.2",
+                "lightningcss-win32-x64-msvc": "1.30.2"
+            }
+        },
+        "node_modules/lightningcss-android-arm64": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+            "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-darwin-arm64": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+            "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-darwin-x64": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+            "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-freebsd-x64": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+            "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-linux-arm-gnueabihf": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+            "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+            "cpu": [
+                "arm"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-linux-arm64-gnu": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+            "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-linux-arm64-musl": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+            "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-linux-x64-gnu": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+            "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-linux-x64-musl": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+            "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-win32-arm64-msvc": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+            "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lightningcss-win32-x64-msvc": {
+            "version": "1.30.2",
+            "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+            "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+            "cpu": [
+                "x64"
+            ],
+            "license": "MPL-2.0",
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">= 12.0.0"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/parcel"
+            }
+        },
+        "node_modules/lru-cache": {
+            "version": "5.1.1",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+            "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+            "dev": true,
+            "license": "ISC",
+            "dependencies": {
+                "yallist": "^3.0.2"
+            }
+        },
+        "node_modules/lucide-react": {
+            "version": "0.562.0",
+            "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
+            "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
+            "license": "ISC",
+            "peerDependencies": {
+                "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+            }
+        },
+        "node_modules/magic-string": {
+            "version": "0.30.21",
+            "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+            "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+            "license": "MIT",
+            "dependencies": {
+                "@jridgewell/sourcemap-codec": "^1.5.5"
+            }
+        },
+        "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/nanoid": {
+            "version": "3.3.11",
+            "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+            "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/ai"
+                }
+            ],
+            "license": "MIT",
+            "bin": {
+                "nanoid": "bin/nanoid.cjs"
+            },
+            "engines": {
+                "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+            }
+        },
+        "node_modules/node-releases": {
+            "version": "2.0.27",
+            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
+            "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+            "dev": true,
+            "license": "MIT"
+        },
+        "node_modules/picocolors": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+            "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+            "license": "ISC"
+        },
+        "node_modules/picomatch": {
+            "version": "4.0.3",
+            "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+            "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+            "license": "MIT",
+            "peer": true,
+            "engines": {
+                "node": ">=12"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/jonschlinkert"
+            }
+        },
+        "node_modules/postcss": {
+            "version": "8.5.6",
+            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+            "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/postcss/"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/postcss"
+                },
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/ai"
+                }
+            ],
+            "license": "MIT",
+            "dependencies": {
+                "nanoid": "^3.3.11",
+                "picocolors": "^1.1.1",
+                "source-map-js": "^1.2.1"
+            },
+            "engines": {
+                "node": "^10 || ^12 || >=14"
+            }
+        },
+        "node_modules/react": {
+            "version": "19.2.4",
+            "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+            "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+            "license": "MIT",
+            "peer": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/react-dom": {
+            "version": "19.2.4",
+            "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+            "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "scheduler": "^0.27.0"
+            },
+            "peerDependencies": {
+                "react": "^19.2.4"
+            }
+        },
+        "node_modules/react-refresh": {
+            "version": "0.18.0",
+            "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+            "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
+            "dev": true,
+            "license": "MIT",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/react-router": {
+            "version": "7.13.0",
+            "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
+            "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
+            "license": "MIT",
+            "dependencies": {
+                "cookie": "^1.0.1",
+                "set-cookie-parser": "^2.6.0"
+            },
+            "engines": {
+                "node": ">=20.0.0"
+            },
+            "peerDependencies": {
+                "react": ">=18",
+                "react-dom": ">=18"
+            },
+            "peerDependenciesMeta": {
+                "react-dom": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/react-router-dom": {
+            "version": "7.13.0",
+            "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
+            "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
+            "license": "MIT",
+            "dependencies": {
+                "react-router": "7.13.0"
+            },
+            "engines": {
+                "node": ">=20.0.0"
+            },
+            "peerDependencies": {
+                "react": ">=18",
+                "react-dom": ">=18"
+            }
+        },
+        "node_modules/rollup": {
+            "version": "4.57.1",
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
+            "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
+            "license": "MIT",
+            "dependencies": {
+                "@types/estree": "1.0.8"
+            },
+            "bin": {
+                "rollup": "dist/bin/rollup"
+            },
+            "engines": {
+                "node": ">=18.0.0",
+                "npm": ">=8.0.0"
+            },
+            "optionalDependencies": {
+                "@rollup/rollup-android-arm-eabi": "4.57.1",
+                "@rollup/rollup-android-arm64": "4.57.1",
+                "@rollup/rollup-darwin-arm64": "4.57.1",
+                "@rollup/rollup-darwin-x64": "4.57.1",
+                "@rollup/rollup-freebsd-arm64": "4.57.1",
+                "@rollup/rollup-freebsd-x64": "4.57.1",
+                "@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
+                "@rollup/rollup-linux-arm-musleabihf": "4.57.1",
+                "@rollup/rollup-linux-arm64-gnu": "4.57.1",
+                "@rollup/rollup-linux-arm64-musl": "4.57.1",
+                "@rollup/rollup-linux-loong64-gnu": "4.57.1",
+                "@rollup/rollup-linux-loong64-musl": "4.57.1",
+                "@rollup/rollup-linux-ppc64-gnu": "4.57.1",
+                "@rollup/rollup-linux-ppc64-musl": "4.57.1",
+                "@rollup/rollup-linux-riscv64-gnu": "4.57.1",
+                "@rollup/rollup-linux-riscv64-musl": "4.57.1",
+                "@rollup/rollup-linux-s390x-gnu": "4.57.1",
+                "@rollup/rollup-linux-x64-gnu": "4.57.1",
+                "@rollup/rollup-linux-x64-musl": "4.57.1",
+                "@rollup/rollup-openbsd-x64": "4.57.1",
+                "@rollup/rollup-openharmony-arm64": "4.57.1",
+                "@rollup/rollup-win32-arm64-msvc": "4.57.1",
+                "@rollup/rollup-win32-ia32-msvc": "4.57.1",
+                "@rollup/rollup-win32-x64-gnu": "4.57.1",
+                "@rollup/rollup-win32-x64-msvc": "4.57.1",
+                "fsevents": "~2.3.2"
+            }
+        },
+        "node_modules/scheduler": {
+            "version": "0.27.0",
+            "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+            "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+            "license": "MIT"
+        },
+        "node_modules/semver": {
+            "version": "6.3.1",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+            "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+            "dev": true,
+            "license": "ISC",
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
+        "node_modules/set-cookie-parser": {
+            "version": "2.7.2",
+            "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+            "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+            "license": "MIT"
+        },
+        "node_modules/source-map-js": {
+            "version": "1.2.1",
+            "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+            "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+            "license": "BSD-3-Clause",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/tailwindcss": {
+            "version": "4.1.18",
+            "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
+            "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+            "license": "MIT"
+        },
+        "node_modules/tapable": {
+            "version": "2.3.0",
+            "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
+            "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+            "license": "MIT",
+            "engines": {
+                "node": ">=6"
+            },
+            "funding": {
+                "type": "opencollective",
+                "url": "https://opencollective.com/webpack"
+            }
+        },
+        "node_modules/tinyglobby": {
+            "version": "0.2.15",
+            "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+            "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+            "license": "MIT",
+            "dependencies": {
+                "fdir": "^6.5.0",
+                "picomatch": "^4.0.3"
+            },
+            "engines": {
+                "node": ">=12.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/SuperchupuDev"
+            }
+        },
+        "node_modules/typescript": {
+            "version": "5.9.3",
+            "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+            "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+            "dev": true,
+            "license": "Apache-2.0",
+            "bin": {
+                "tsc": "bin/tsc",
+                "tsserver": "bin/tsserver"
+            },
+            "engines": {
+                "node": ">=14.17"
+            }
+        },
+        "node_modules/update-browserslist-db": {
+            "version": "1.2.3",
+            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+            "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+            "dev": true,
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/browserslist"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/browserslist"
+                },
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/ai"
+                }
+            ],
+            "license": "MIT",
+            "dependencies": {
+                "escalade": "^3.2.0",
+                "picocolors": "^1.1.1"
+            },
+            "bin": {
+                "update-browserslist-db": "cli.js"
+            },
+            "peerDependencies": {
+                "browserslist": ">= 4.21.0"
+            }
+        },
+        "node_modules/vite": {
+            "version": "7.3.1",
+            "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+            "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+            "license": "MIT",
+            "peer": true,
+            "dependencies": {
+                "esbuild": "^0.27.0",
+                "fdir": "^6.5.0",
+                "picomatch": "^4.0.3",
+                "postcss": "^8.5.6",
+                "rollup": "^4.43.0",
+                "tinyglobby": "^0.2.15"
+            },
+            "bin": {
+                "vite": "bin/vite.js"
+            },
+            "engines": {
+                "node": "^20.19.0 || >=22.12.0"
+            },
+            "funding": {
+                "url": "https://github.com/vitejs/vite?sponsor=1"
+            },
+            "optionalDependencies": {
+                "fsevents": "~2.3.3"
+            },
+            "peerDependencies": {
+                "@types/node": "^20.19.0 || >=22.12.0",
+                "jiti": ">=1.21.0",
+                "less": "^4.0.0",
+                "lightningcss": "^1.21.0",
+                "sass": "^1.70.0",
+                "sass-embedded": "^1.70.0",
+                "stylus": ">=0.54.8",
+                "sugarss": "^5.0.0",
+                "terser": "^5.16.0",
+                "tsx": "^4.8.1",
+                "yaml": "^2.4.2"
+            },
+            "peerDependenciesMeta": {
+                "@types/node": {
+                    "optional": true
+                },
+                "jiti": {
+                    "optional": true
+                },
+                "less": {
+                    "optional": true
+                },
+                "lightningcss": {
+                    "optional": true
+                },
+                "sass": {
+                    "optional": true
+                },
+                "sass-embedded": {
+                    "optional": true
+                },
+                "stylus": {
+                    "optional": true
+                },
+                "sugarss": {
+                    "optional": true
+                },
+                "terser": {
+                    "optional": true
+                },
+                "tsx": {
+                    "optional": true
+                },
+                "yaml": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/yallist": {
+            "version": "3.1.1",
+            "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+            "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+            "dev": true,
+            "license": "ISC"
+        },
+        "node_modules/zustand": {
+            "version": "5.0.11",
+            "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
+            "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
+            "license": "MIT",
+            "engines": {
+                "node": ">=12.20.0"
+            },
+            "peerDependencies": {
+                "@types/react": ">=18.0.0",
+                "immer": ">=9.0.6",
+                "react": ">=18.0.0",
+                "use-sync-external-store": ">=1.2.0"
+            },
+            "peerDependenciesMeta": {
+                "@types/react": {
+                    "optional": true
+                },
+                "immer": {
+                    "optional": true
+                },
+                "react": {
+                    "optional": true
+                },
+                "use-sync-external-store": {
+                    "optional": true
+                }
+            }
+        }
+    }
+}

+ 29 - 0
webui/package.json

@@ -0,0 +1,29 @@
+{
+    "name": "smartbotic-microbit-webui",
+    "private": true,
+    "version": "0.1.0",
+    "type": "module",
+    "scripts": {
+        "dev": "vite",
+        "build": "tsc -b && vite build",
+        "preview": "vite preview"
+    },
+    "dependencies": {
+        "@tailwindcss/vite": "^4.1.18",
+        "@tanstack/react-query": "^5.90.16",
+        "clsx": "^2.1.1",
+        "lucide-react": "^0.562.0",
+        "react": "^19.2.0",
+        "react-dom": "^19.2.0",
+        "react-router-dom": "^7.11.0",
+        "tailwindcss": "^4.1.18",
+        "zustand": "^5.0.10"
+    },
+    "devDependencies": {
+        "@types/react": "^19.2.5",
+        "@types/react-dom": "^19.2.3",
+        "@vitejs/plugin-react": "^5.1.1",
+        "typescript": "~5.9.3",
+        "vite": "^7.2.4"
+    }
+}

+ 57 - 0
webui/src/App.tsx

@@ -0,0 +1,57 @@
+import { Routes, Route } from "react-router-dom";
+import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
+import { MainLayout } from "@/components/layout/MainLayout";
+import { LoginPage } from "@/pages/auth/LoginPage";
+import { RegisterPage } from "@/pages/auth/RegisterPage";
+import { Dashboard } from "@/pages/dashboard/Dashboard";
+import { WorkspaceSettings } from "@/pages/workspaces/WorkspaceSettings";
+import { MembersList } from "@/pages/members/MembersList";
+import { AssistantsList } from "@/pages/assistants/AssistantsList";
+import { AssistantEditor } from "@/pages/assistants/AssistantEditor";
+import { CalendarManager } from "@/pages/calendar/CalendarManager";
+import { TimeSlotsEditor } from "@/pages/calendar/TimeSlotsEditor";
+import { AppointmentsList } from "@/pages/calendar/AppointmentsList";
+import { PendingInvitations } from "@/pages/invitations/PendingInvitations";
+import { GlobalSettings } from "@/pages/settings/GlobalSettings";
+
+export default function App() {
+    return (
+        <Routes>
+            {/* Public routes */}
+            <Route path="/login" element={<LoginPage />} />
+            <Route path="/register" element={<RegisterPage />} />
+            <Route path="/register/:token" element={<RegisterPage />} />
+
+            {/* Protected routes */}
+            <Route element={<ProtectedRoute />}>
+                <Route element={<MainLayout />}>
+                    <Route index element={<Dashboard />} />
+                    <Route
+                        path="workspace/settings"
+                        element={<WorkspaceSettings />}
+                    />
+                    <Route path="members" element={<MembersList />} />
+                    <Route path="assistants" element={<AssistantsList />} />
+                    <Route
+                        path="assistants/:id"
+                        element={<AssistantEditor />}
+                    />
+                    <Route path="calendars" element={<CalendarManager />} />
+                    <Route
+                        path="calendars/:calId/time-slots"
+                        element={<TimeSlotsEditor />}
+                    />
+                    <Route
+                        path="appointments"
+                        element={<AppointmentsList />}
+                    />
+                    <Route
+                        path="invitations"
+                        element={<PendingInvitations />}
+                    />
+                    <Route path="settings" element={<GlobalSettings />} />
+                </Route>
+            </Route>
+        </Routes>
+    );
+}

+ 477 - 0
webui/src/api/client.ts

@@ -0,0 +1,477 @@
+import type {
+    LoginResponse,
+    RegisterRequest,
+    User,
+    Workspace,
+    WorkspaceMember,
+    Invitation,
+    Assistant,
+    PhoneNumber,
+    ProviderConfig,
+    Calendar,
+    TimeSlot,
+    Appointment,
+    GlobalSettings,
+    SmtpSettings,
+    CallerAiSettings,
+} from "@/types";
+
+const BASE_URL = "/api/v1";
+
+// ── ApiError ─────────────────────────────────────────────────────────────────
+
+export class ApiError extends Error {
+    status: number;
+    body: unknown;
+
+    constructor(status: number, body: unknown) {
+        const message =
+            typeof body === "object" && body !== null && "detail" in body
+                ? String((body as { detail: string }).detail)
+                : `Request failed with status ${status}`;
+        super(message);
+        this.name = "ApiError";
+        this.status = status;
+        this.body = body;
+    }
+}
+
+// ── Core request helper ──────────────────────────────────────────────────────
+
+let getAccessToken: () => string | null = () => null;
+let getRefreshToken: () => string | null = () => null;
+let setTokens: (access: string, refresh: string) => void = () => {};
+let clearAuth: () => void = () => {};
+
+export function bindAuthStore(fns: {
+    getAccessToken: () => string | null;
+    getRefreshToken: () => string | null;
+    setTokens: (access: string, refresh: string) => void;
+    clearAuth: () => void;
+}) {
+    getAccessToken = fns.getAccessToken;
+    getRefreshToken = fns.getRefreshToken;
+    setTokens = fns.setTokens;
+    clearAuth = fns.clearAuth;
+}
+
+let isRefreshing = false;
+let refreshPromise: Promise<boolean> | null = null;
+
+async function attemptRefresh(): Promise<boolean> {
+    const rt = getRefreshToken();
+    if (!rt) return false;
+
+    try {
+        const res = await fetch(`${BASE_URL}/auth/refresh`, {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({ refresh_token: rt }),
+        });
+        if (!res.ok) return false;
+        const data = (await res.json()) as {
+            access_token: string;
+            refresh_token: string;
+        };
+        setTokens(data.access_token, data.refresh_token);
+        return true;
+    } catch {
+        return false;
+    }
+}
+
+async function request<T>(
+    path: string,
+    options: RequestInit = {},
+): Promise<T> {
+    const headers = new Headers(options.headers);
+
+    const token = getAccessToken();
+    if (token) {
+        headers.set("Authorization", `Bearer ${token}`);
+    }
+    if (
+        options.body &&
+        typeof options.body === "string" &&
+        !headers.has("Content-Type")
+    ) {
+        headers.set("Content-Type", "application/json");
+    }
+
+    const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
+
+    if (res.status === 401 && token) {
+        // Try refresh once
+        if (!isRefreshing) {
+            isRefreshing = true;
+            refreshPromise = attemptRefresh().finally(() => {
+                isRefreshing = false;
+                refreshPromise = null;
+            });
+        }
+
+        const refreshed = await (refreshPromise ?? Promise.resolve(false));
+        if (refreshed) {
+            // Retry original request
+            const retryHeaders = new Headers(options.headers);
+            const newToken = getAccessToken();
+            if (newToken) retryHeaders.set("Authorization", `Bearer ${newToken}`);
+            if (
+                options.body &&
+                typeof options.body === "string" &&
+                !retryHeaders.has("Content-Type")
+            ) {
+                retryHeaders.set("Content-Type", "application/json");
+            }
+            const retryRes = await fetch(`${BASE_URL}${path}`, {
+                ...options,
+                headers: retryHeaders,
+            });
+            if (!retryRes.ok) {
+                const body = await retryRes.json().catch(() => null);
+                throw new ApiError(retryRes.status, body);
+            }
+            if (retryRes.status === 204) return undefined as T;
+            return retryRes.json() as Promise<T>;
+        }
+
+        clearAuth();
+        throw new ApiError(401, { detail: "Session expired" });
+    }
+
+    if (!res.ok) {
+        const body = await res.json().catch(() => null);
+        throw new ApiError(res.status, body);
+    }
+
+    if (res.status === 204) return undefined as T;
+    return res.json() as Promise<T>;
+}
+
+// ── Auth API ─────────────────────────────────────────────────────────────────
+
+export const authApi = {
+    login(email: string, password: string) {
+        return request<LoginResponse>("/auth/login", {
+            method: "POST",
+            body: JSON.stringify({ email, password }),
+        });
+    },
+    register(data: RegisterRequest) {
+        return request<LoginResponse>("/auth/register", {
+            method: "POST",
+            body: JSON.stringify(data),
+        });
+    },
+    me() {
+        return request<User>("/auth/me");
+    },
+    refresh(refreshToken: string) {
+        return request<{ access_token: string; refresh_token: string }>(
+            "/auth/refresh",
+            {
+                method: "POST",
+                body: JSON.stringify({ refresh_token: refreshToken }),
+            },
+        );
+    },
+};
+
+// ── Workspace API ────────────────────────────────────────────────────────────
+
+export const workspaceApi = {
+    list() {
+        return request<Workspace[]>("/workspaces");
+    },
+    get(id: string) {
+        return request<Workspace>(`/workspaces/${id}`);
+    },
+    create(data: Partial<Workspace>) {
+        return request<Workspace>("/workspaces", {
+            method: "POST",
+            body: JSON.stringify(data),
+        });
+    },
+    update(id: string, data: Partial<Workspace>) {
+        return request<Workspace>(`/workspaces/${id}`, {
+            method: "PUT",
+            body: JSON.stringify(data),
+        });
+    },
+    delete(id: string) {
+        return request<void>(`/workspaces/${id}`, { method: "DELETE" });
+    },
+};
+
+// ── Member API ───────────────────────────────────────────────────────────────
+
+export const memberApi = {
+    list(workspaceId: string) {
+        return request<WorkspaceMember[]>(
+            `/workspaces/${workspaceId}/members`,
+        );
+    },
+    updateRole(
+        workspaceId: string,
+        memberId: string,
+        role: string,
+    ) {
+        return request<WorkspaceMember>(
+            `/workspaces/${workspaceId}/members/${memberId}`,
+            {
+                method: "PUT",
+                body: JSON.stringify({ role }),
+            },
+        );
+    },
+    remove(workspaceId: string, memberId: string) {
+        return request<void>(
+            `/workspaces/${workspaceId}/members/${memberId}`,
+            { method: "DELETE" },
+        );
+    },
+};
+
+// ── Invitation API ───────────────────────────────────────────────────────────
+
+export const invitationApi = {
+    list(workspaceId: string) {
+        return request<Invitation[]>(
+            `/workspaces/${workspaceId}/invitations`,
+        );
+    },
+    create(
+        workspaceId: string,
+        data: { email: string; role: string },
+    ) {
+        return request<Invitation>(
+            `/workspaces/${workspaceId}/invitations`,
+            {
+                method: "POST",
+                body: JSON.stringify(data),
+            },
+        );
+    },
+    revoke(workspaceId: string, invitationId: string) {
+        return request<void>(
+            `/workspaces/${workspaceId}/invitations/${invitationId}`,
+            { method: "DELETE" },
+        );
+    },
+    pending() {
+        return request<Invitation[]>("/invitations/pending");
+    },
+    accept(invitationId: string) {
+        return request<void>(`/invitations/${invitationId}/accept`, {
+            method: "POST",
+        });
+    },
+    decline(invitationId: string) {
+        return request<void>(`/invitations/${invitationId}/decline`, {
+            method: "POST",
+        });
+    },
+};
+
+// ── Assistant API ────────────────────────────────────────────────────────────
+
+export const assistantApi = {
+    list(workspaceId: string) {
+        return request<Assistant[]>(
+            `/workspaces/${workspaceId}/assistants`,
+        );
+    },
+    get(workspaceId: string, assistantId: string) {
+        return request<Assistant>(
+            `/workspaces/${workspaceId}/assistants/${assistantId}`,
+        );
+    },
+    create(
+        workspaceId: string,
+        data: { name: string; phone_number_id: string },
+    ) {
+        return request<Assistant>(
+            `/workspaces/${workspaceId}/assistants`,
+            {
+                method: "POST",
+                body: JSON.stringify(data),
+            },
+        );
+    },
+    update(
+        workspaceId: string,
+        assistantId: string,
+        data: Partial<Assistant>,
+    ) {
+        return request<Assistant>(
+            `/workspaces/${workspaceId}/assistants/${assistantId}`,
+            {
+                method: "PUT",
+                body: JSON.stringify(data),
+            },
+        );
+    },
+    delete(workspaceId: string, assistantId: string) {
+        return request<void>(
+            `/workspaces/${workspaceId}/assistants/${assistantId}`,
+            { method: "DELETE" },
+        );
+    },
+};
+
+// ── CallerAI API (phone numbers / voices) ────────────────────────────────────
+
+export const calleraiApi = {
+    phoneNumbers() {
+        return request<PhoneNumber[]>("/callerai/phone-numbers");
+    },
+    voiceProviders() {
+        return request<ProviderConfig[]>("/callerai/voice-providers");
+    },
+};
+
+// ── Calendar API ─────────────────────────────────────────────────────────────
+
+export const calendarApi = {
+    list(workspaceId: string) {
+        return request<Calendar[]>(
+            `/workspaces/${workspaceId}/calendars`,
+        );
+    },
+    get(workspaceId: string, calendarId: string) {
+        return request<Calendar>(
+            `/workspaces/${workspaceId}/calendars/${calendarId}`,
+        );
+    },
+    create(workspaceId: string, data: Partial<Calendar>) {
+        return request<Calendar>(
+            `/workspaces/${workspaceId}/calendars`,
+            {
+                method: "POST",
+                body: JSON.stringify(data),
+            },
+        );
+    },
+    update(
+        workspaceId: string,
+        calendarId: string,
+        data: Partial<Calendar>,
+    ) {
+        return request<Calendar>(
+            `/workspaces/${workspaceId}/calendars/${calendarId}`,
+            {
+                method: "PUT",
+                body: JSON.stringify(data),
+            },
+        );
+    },
+    delete(workspaceId: string, calendarId: string) {
+        return request<void>(
+            `/workspaces/${workspaceId}/calendars/${calendarId}`,
+            { method: "DELETE" },
+        );
+    },
+};
+
+// ── TimeSlot API ─────────────────────────────────────────────────────────────
+
+export const timeSlotApi = {
+    list(workspaceId: string, calendarId: string) {
+        return request<TimeSlot[]>(
+            `/workspaces/${workspaceId}/calendars/${calendarId}/time-slots`,
+        );
+    },
+    create(
+        workspaceId: string,
+        calendarId: string,
+        data: Partial<TimeSlot>,
+    ) {
+        return request<TimeSlot>(
+            `/workspaces/${workspaceId}/calendars/${calendarId}/time-slots`,
+            {
+                method: "POST",
+                body: JSON.stringify(data),
+            },
+        );
+    },
+    update(
+        workspaceId: string,
+        calendarId: string,
+        slotId: string,
+        data: Partial<TimeSlot>,
+    ) {
+        return request<TimeSlot>(
+            `/workspaces/${workspaceId}/calendars/${calendarId}/time-slots/${slotId}`,
+            {
+                method: "PUT",
+                body: JSON.stringify(data),
+            },
+        );
+    },
+    delete(
+        workspaceId: string,
+        calendarId: string,
+        slotId: string,
+    ) {
+        return request<void>(
+            `/workspaces/${workspaceId}/calendars/${calendarId}/time-slots/${slotId}`,
+            { method: "DELETE" },
+        );
+    },
+};
+
+// ── Appointment API ──────────────────────────────────────────────────────────
+
+export const appointmentApi = {
+    list(
+        workspaceId: string,
+        params?: { from?: string; to?: string; status?: string },
+    ) {
+        const qs = new URLSearchParams();
+        if (params?.from) qs.set("from", params.from);
+        if (params?.to) qs.set("to", params.to);
+        if (params?.status) qs.set("status", params.status);
+        const query = qs.toString() ? `?${qs.toString()}` : "";
+        return request<Appointment[]>(
+            `/workspaces/${workspaceId}/appointments${query}`,
+        );
+    },
+    cancel(workspaceId: string, appointmentId: string) {
+        return request<Appointment>(
+            `/workspaces/${workspaceId}/appointments/${appointmentId}/cancel`,
+            { method: "POST" },
+        );
+    },
+};
+
+// ── Settings API ─────────────────────────────────────────────────────────────
+
+export const settingsApi = {
+    get() {
+        return request<GlobalSettings>("/settings");
+    },
+    updateSmtp(data: SmtpSettings) {
+        return request<SmtpSettings>("/settings/smtp", {
+            method: "PUT",
+            body: JSON.stringify(data),
+        });
+    },
+    testSmtp() {
+        return request<{ success: boolean; message: string }>(
+            "/settings/smtp/test",
+            { method: "POST" },
+        );
+    },
+    updateCallerAi(data: CallerAiSettings) {
+        return request<CallerAiSettings>("/settings/callerai", {
+            method: "PUT",
+            body: JSON.stringify(data),
+        });
+    },
+    testCallerAi() {
+        return request<{ success: boolean; message: string }>(
+            "/settings/callerai/test",
+            { method: "POST" },
+        );
+    },
+};

+ 12 - 0
webui/src/components/auth/ProtectedRoute.tsx

@@ -0,0 +1,12 @@
+import { Navigate, Outlet } from "react-router-dom";
+import { useAuthStore } from "@/stores/authStore";
+
+export function ProtectedRoute() {
+    const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+
+    if (!isAuthenticated) {
+        return <Navigate to="/login" replace />;
+    }
+
+    return <Outlet />;
+}

+ 128 - 0
webui/src/components/layout/Header.tsx

@@ -0,0 +1,128 @@
+import { useState, useRef, useEffect } from "react";
+import { ChevronDown, LogOut, User as UserIcon } from "lucide-react";
+import { useAuthStore } from "@/stores/authStore";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import type { Workspace } from "@/types";
+
+export function Header() {
+    const user = useAuthStore((s) => s.user);
+    const logout = useAuthStore((s) => s.logout);
+    const clearWorkspace = useWorkspaceStore((s) => s.clearWorkspace);
+    const { activeWorkspace, workspaces, setActiveWorkspace } =
+        useWorkspaceStore();
+
+    const [wsOpen, setWsOpen] = useState(false);
+    const [userOpen, setUserOpen] = useState(false);
+    const wsRef = useRef<HTMLDivElement>(null);
+    const userRef = useRef<HTMLDivElement>(null);
+
+    // Close dropdowns on outside click
+    useEffect(() => {
+        function handleClick(e: MouseEvent) {
+            if (wsRef.current && !wsRef.current.contains(e.target as Node)) {
+                setWsOpen(false);
+            }
+            if (
+                userRef.current &&
+                !userRef.current.contains(e.target as Node)
+            ) {
+                setUserOpen(false);
+            }
+        }
+        document.addEventListener("mousedown", handleClick);
+        return () => document.removeEventListener("mousedown", handleClick);
+    }, []);
+
+    function handleSelectWorkspace(ws: Workspace) {
+        setActiveWorkspace(ws);
+        setWsOpen(false);
+    }
+
+    function handleLogout() {
+        logout();
+        clearWorkspace();
+    }
+
+    return (
+        <header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-6">
+            {/* Workspace selector */}
+            <div ref={wsRef} className="relative">
+                <button
+                    onClick={() => setWsOpen((o) => !o)}
+                    className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
+                >
+                    {activeWorkspace?.company_name ?? "Select workspace"}
+                    <ChevronDown className="h-4 w-4 text-gray-400" />
+                </button>
+
+                {wsOpen && workspaces.length > 0 && (
+                    <div className="absolute left-0 top-full z-20 mt-1 w-64 rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
+                        {workspaces.map((ws) => (
+                            <button
+                                key={ws.id}
+                                onClick={() => handleSelectWorkspace(ws)}
+                                className={`w-full px-4 py-2 text-left text-sm transition-colors ${
+                                    ws.id === activeWorkspace?.id
+                                        ? "bg-blue-50 text-blue-700 font-medium"
+                                        : "text-gray-700 hover:bg-gray-50"
+                                }`}
+                            >
+                                {ws.company_name}
+                            </button>
+                        ))}
+                    </div>
+                )}
+            </div>
+
+            {/* User menu */}
+            <div ref={userRef} className="relative">
+                <button
+                    onClick={() => setUserOpen((o) => !o)}
+                    className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
+                >
+                    <div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-blue-700 text-xs font-semibold">
+                        {user?.display_name
+                            ?.split(" ")
+                            .map((n) => n[0])
+                            .join("")
+                            .toUpperCase()
+                            .slice(0, 2) ?? "?"}
+                    </div>
+                    <span className="hidden sm:inline">
+                        {user?.display_name ?? user?.email}
+                    </span>
+                    <ChevronDown className="h-4 w-4 text-gray-400" />
+                </button>
+
+                {userOpen && (
+                    <div className="absolute right-0 top-full z-20 mt-1 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
+                        <div className="border-b border-gray-100 px-4 py-2">
+                            <p className="text-sm font-medium text-gray-900 truncate">
+                                {user?.display_name}
+                            </p>
+                            <p className="text-xs text-gray-500 truncate">
+                                {user?.email}
+                            </p>
+                        </div>
+                        <button
+                            onClick={() => {
+                                setUserOpen(false);
+                            }}
+                            className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
+                        >
+                            <UserIcon className="h-4 w-4" />
+                            Profile
+                        </button>
+                        <button
+                            onClick={handleLogout}
+                            className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 hover:bg-red-50"
+                        >
+                            <LogOut className="h-4 w-4" />
+                            Log out
+                        </button>
+                    </div>
+                )}
+            </div>
+        </header>
+    );
+}

+ 56 - 0
webui/src/components/layout/MainLayout.tsx

@@ -0,0 +1,56 @@
+import { Outlet } from "react-router-dom";
+import { useEffect } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { Sidebar } from "./Sidebar";
+import { Header } from "./Header";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { workspaceApi } from "@/api/client";
+import { Spinner } from "@/components/ui/Spinner";
+
+export function MainLayout() {
+    const { activeWorkspace, setActiveWorkspace, setWorkspaces } =
+        useWorkspaceStore();
+
+    const { data: workspaces, isLoading } = useQuery({
+        queryKey: ["workspaces"],
+        queryFn: () => workspaceApi.list(),
+    });
+
+    useEffect(() => {
+        if (!workspaces) return;
+        setWorkspaces(workspaces);
+        // Auto-select first workspace if none selected
+        if (!activeWorkspace && workspaces.length > 0) {
+            setActiveWorkspace(workspaces[0]!);
+        }
+        // If active workspace was deleted, pick first
+        if (
+            activeWorkspace &&
+            !workspaces.find((w) => w.id === activeWorkspace.id)
+        ) {
+            if (workspaces.length > 0) {
+                setActiveWorkspace(workspaces[0]!);
+            }
+        }
+    }, [workspaces, activeWorkspace, setActiveWorkspace, setWorkspaces]);
+
+    if (isLoading) {
+        return (
+            <div className="flex h-screen items-center justify-center">
+                <Spinner size="lg" />
+            </div>
+        );
+    }
+
+    return (
+        <div className="flex h-screen bg-gray-50">
+            <Sidebar />
+            <div className="flex flex-1 flex-col overflow-hidden">
+                <Header />
+                <main className="flex-1 overflow-y-auto p-6">
+                    <Outlet />
+                </main>
+            </div>
+        </div>
+    );
+}

+ 87 - 0
webui/src/components/layout/Sidebar.tsx

@@ -0,0 +1,87 @@
+import { NavLink } from "react-router-dom";
+import {
+    LayoutDashboard,
+    Users,
+    Bot,
+    CalendarDays,
+    CalendarCheck,
+    Mail,
+    Settings,
+} from "lucide-react";
+import clsx from "clsx";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+
+const navItems = [
+    { to: "/", icon: LayoutDashboard, label: "Dashboard", end: true },
+    { to: "/members", icon: Users, label: "Members", end: false },
+    { to: "/assistants", icon: Bot, label: "Assistants", end: false },
+    { to: "/calendars", icon: CalendarDays, label: "Calendars", end: false },
+    { to: "/appointments", icon: CalendarCheck, label: "Appointments", end: false },
+    { to: "/invitations", icon: Mail, label: "Invitations", end: false },
+    { to: "/settings", icon: Settings, label: "Settings", end: false },
+];
+
+export function Sidebar() {
+    const workspace = useWorkspaceStore((s) => s.activeWorkspace);
+
+    return (
+        <aside className="flex h-full w-64 flex-col border-r border-gray-200 bg-white">
+            {/* Brand */}
+            <div className="flex items-center gap-3 border-b border-gray-200 px-5 py-5">
+                <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-600 text-white font-bold text-sm">
+                    SB
+                </div>
+                <div className="min-w-0">
+                    <p className="text-sm font-semibold text-gray-900 truncate">
+                        Smartbotic
+                    </p>
+                    {workspace && (
+                        <p className="text-xs text-gray-500 truncate">
+                            {workspace.company_name}
+                        </p>
+                    )}
+                </div>
+            </div>
+
+            {/* Navigation */}
+            <nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1">
+                {navItems.map((item) => (
+                    <NavLink
+                        key={item.to}
+                        to={item.to}
+                        end={item.end}
+                        className={({ isActive }) =>
+                            clsx(
+                                "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
+                                isActive
+                                    ? "bg-blue-50 text-blue-700"
+                                    : "text-gray-600 hover:bg-gray-50 hover:text-gray-900",
+                            )
+                        }
+                    >
+                        <item.icon className="h-5 w-5 shrink-0" />
+                        {item.label}
+                    </NavLink>
+                ))}
+            </nav>
+
+            {/* Workspace settings link */}
+            <div className="border-t border-gray-200 px-3 py-3">
+                <NavLink
+                    to="/workspace/settings"
+                    className={({ isActive }) =>
+                        clsx(
+                            "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
+                            isActive
+                                ? "bg-blue-50 text-blue-700"
+                                : "text-gray-600 hover:bg-gray-50 hover:text-gray-900",
+                        )
+                    }
+                >
+                    <Settings className="h-5 w-5 shrink-0" />
+                    Workspace Settings
+                </NavLink>
+            </div>
+        </aside>
+    );
+}

+ 32 - 0
webui/src/components/ui/Badge.tsx

@@ -0,0 +1,32 @@
+import clsx from "clsx";
+import type { ReactNode } from "react";
+
+type BadgeVariant = "success" | "warning" | "danger" | "info" | "default";
+
+interface BadgeProps {
+    variant?: BadgeVariant;
+    children: ReactNode;
+    className?: string;
+}
+
+const variantStyles: Record<BadgeVariant, string> = {
+    success: "bg-green-100 text-green-700",
+    warning: "bg-yellow-100 text-yellow-700",
+    danger: "bg-red-100 text-red-700",
+    info: "bg-blue-100 text-blue-700",
+    default: "bg-gray-100 text-gray-700",
+};
+
+export function Badge({ variant = "default", children, className }: BadgeProps) {
+    return (
+        <span
+            className={clsx(
+                "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
+                variantStyles[variant],
+                className,
+            )}
+        >
+            {children}
+        </span>
+    );
+}

+ 62 - 0
webui/src/components/ui/Button.tsx

@@ -0,0 +1,62 @@
+import { forwardRef, type ButtonHTMLAttributes } from "react";
+import clsx from "clsx";
+import { Spinner } from "./Spinner";
+
+type Variant = "primary" | "secondary" | "danger" | "ghost";
+type Size = "sm" | "md" | "lg";
+
+interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
+    variant?: Variant;
+    size?: Size;
+    loading?: boolean;
+}
+
+const variantStyles: Record<Variant, string> = {
+    primary:
+        "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 shadow-sm",
+    secondary:
+        "bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-blue-500 shadow-sm",
+    danger:
+        "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 shadow-sm",
+    ghost: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:ring-gray-500",
+};
+
+const sizeStyles: Record<Size, string> = {
+    sm: "px-3 py-1.5 text-sm",
+    md: "px-4 py-2 text-sm",
+    lg: "px-6 py-3 text-base",
+};
+
+export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
+    (
+        {
+            variant = "primary",
+            size = "md",
+            loading = false,
+            disabled,
+            className,
+            children,
+            ...props
+        },
+        ref,
+    ) => {
+        return (
+            <button
+                ref={ref}
+                disabled={disabled || loading}
+                className={clsx(
+                    "inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed",
+                    variantStyles[variant],
+                    sizeStyles[size],
+                    className,
+                )}
+                {...props}
+            >
+                {loading && <Spinner size="sm" />}
+                {children}
+            </button>
+        );
+    },
+);
+
+Button.displayName = "Button";

+ 29 - 0
webui/src/components/ui/Card.tsx

@@ -0,0 +1,29 @@
+import type { ReactNode } from "react";
+import clsx from "clsx";
+
+interface CardProps {
+    title?: string;
+    children: ReactNode;
+    className?: string;
+    padding?: boolean;
+}
+
+export function Card({ title, children, className, padding = true }: CardProps) {
+    return (
+        <div
+            className={clsx(
+                "bg-white rounded-xl border border-gray-200 shadow-sm",
+                className,
+            )}
+        >
+            {title && (
+                <div className="border-b border-gray-200 px-6 py-4">
+                    <h3 className="text-lg font-semibold text-gray-900">
+                        {title}
+                    </h3>
+                </div>
+            )}
+            <div className={clsx(padding && "px-6 py-4")}>{children}</div>
+        </div>
+    );
+}

+ 45 - 0
webui/src/components/ui/Input.tsx

@@ -0,0 +1,45 @@
+import { forwardRef, type InputHTMLAttributes } from "react";
+import clsx from "clsx";
+
+interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
+    label?: string;
+    error?: string;
+}
+
+export const Input = forwardRef<HTMLInputElement, InputProps>(
+    ({ label, error, className, id, ...props }, ref) => {
+        const inputId = id ?? label?.toLowerCase().replace(/\s+/g, "-");
+
+        return (
+            <div className="w-full">
+                {label && (
+                    <label
+                        htmlFor={inputId}
+                        className="block text-sm font-medium text-gray-700 mb-1"
+                    >
+                        {label}
+                    </label>
+                )}
+                <input
+                    ref={ref}
+                    id={inputId}
+                    className={clsx(
+                        "block w-full rounded-lg border px-3 py-2 text-sm shadow-sm transition-colors",
+                        "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500",
+                        "placeholder:text-gray-400",
+                        error
+                            ? "border-red-300 text-red-900 focus:ring-red-500 focus:border-red-500"
+                            : "border-gray-300 text-gray-900",
+                        className,
+                    )}
+                    {...props}
+                />
+                {error && (
+                    <p className="mt-1 text-sm text-red-600">{error}</p>
+                )}
+            </div>
+        );
+    },
+);
+
+Input.displayName = "Input";

+ 50 - 0
webui/src/components/ui/Modal.tsx

@@ -0,0 +1,50 @@
+import { useEffect, type ReactNode } from "react";
+import { X } from "lucide-react";
+
+interface ModalProps {
+    open: boolean;
+    onClose: () => void;
+    title: string;
+    children: ReactNode;
+    wide?: boolean;
+}
+
+export function Modal({ open, onClose, title, children, wide = false }: ModalProps) {
+    useEffect(() => {
+        if (!open) return;
+        const handler = (e: KeyboardEvent) => {
+            if (e.key === "Escape") onClose();
+        };
+        document.addEventListener("keydown", handler);
+        return () => document.removeEventListener("keydown", handler);
+    }, [open, onClose]);
+
+    if (!open) return null;
+
+    return (
+        <div className="fixed inset-0 z-50 flex items-center justify-center">
+            {/* Backdrop */}
+            <div
+                className="absolute inset-0 bg-black/50 transition-opacity"
+                onClick={onClose}
+            />
+            {/* Panel */}
+            <div
+                className={`relative bg-white rounded-xl shadow-xl w-full mx-4 max-h-[90vh] flex flex-col ${wide ? "max-w-2xl" : "max-w-md"}`}
+            >
+                <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
+                    <h2 className="text-lg font-semibold text-gray-900">
+                        {title}
+                    </h2>
+                    <button
+                        onClick={onClose}
+                        className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
+                    >
+                        <X className="h-5 w-5" />
+                    </button>
+                </div>
+                <div className="overflow-y-auto px-6 py-4">{children}</div>
+            </div>
+        </div>
+    );
+}

+ 37 - 0
webui/src/components/ui/Spinner.tsx

@@ -0,0 +1,37 @@
+import clsx from "clsx";
+
+interface SpinnerProps {
+    size?: "sm" | "md" | "lg";
+    className?: string;
+}
+
+const sizeMap = {
+    sm: "h-4 w-4",
+    md: "h-6 w-6",
+    lg: "h-10 w-10",
+};
+
+export function Spinner({ size = "md", className }: SpinnerProps) {
+    return (
+        <svg
+            className={clsx("animate-spin text-blue-600", sizeMap[size], className)}
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+        >
+            <circle
+                className="opacity-25"
+                cx="12"
+                cy="12"
+                r="10"
+                stroke="currentColor"
+                strokeWidth="4"
+            />
+            <path
+                className="opacity-75"
+                fill="currentColor"
+                d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+            />
+        </svg>
+    );
+}

+ 1 - 0
webui/src/index.css

@@ -0,0 +1 @@
+@import "tailwindcss";

+ 26 - 0
webui/src/main.tsx

@@ -0,0 +1,26 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import App from "./App";
+import "./index.css";
+
+const queryClient = new QueryClient({
+    defaultOptions: {
+        queries: {
+            staleTime: 30_000,
+            retry: 1,
+            refetchOnWindowFocus: false,
+        },
+    },
+});
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+    <React.StrictMode>
+        <QueryClientProvider client={queryClient}>
+            <BrowserRouter>
+                <App />
+            </BrowserRouter>
+        </QueryClientProvider>
+    </React.StrictMode>,
+);

+ 291 - 0
webui/src/pages/assistants/AssistantEditor.tsx

@@ -0,0 +1,291 @@
+import { useState, useEffect, type FormEvent } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { ArrowLeft } from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Input } from "@/components/ui/Input";
+import { Button } from "@/components/ui/Button";
+import { Spinner } from "@/components/ui/Spinner";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { assistantApi, calleraiApi } from "@/api/client";
+import type { AssistantConfig } from "@/types";
+
+export function AssistantEditor() {
+    const { id } = useParams<{ id: string }>();
+    const navigate = useNavigate();
+    const queryClient = useQueryClient();
+    const workspace = useWorkspaceStore((s) => s.activeWorkspace);
+    const wsId = workspace?.id ?? "";
+
+    const [config, setConfig] = useState<AssistantConfig>({
+        company_name: "",
+        company_description: "",
+        greeting_message: "",
+        end_call_phrases: [],
+        company_address: "",
+        company_phone: "",
+        voice_provider: "",
+        voice_id: "",
+    });
+    const [endCallPhrasesText, setEndCallPhrasesText] = useState("");
+    const [success, setSuccess] = useState("");
+    const [error, setError] = useState("");
+
+    const { data: assistant, isLoading } = useQuery({
+        queryKey: ["assistant", wsId, id],
+        queryFn: () => assistantApi.get(wsId, id!),
+        enabled: !!wsId && !!id,
+    });
+
+    const { data: providers } = useQuery({
+        queryKey: ["voice-providers"],
+        queryFn: () => calleraiApi.voiceProviders(),
+    });
+
+    useEffect(() => {
+        if (assistant?.custom_config) {
+            const cfg = assistant.custom_config;
+            setConfig(cfg);
+            setEndCallPhrasesText(cfg.end_call_phrases?.join(", ") ?? "");
+        }
+    }, [assistant]);
+
+    const selectedProvider = providers?.find(
+        (p) => p.provider === config.voice_provider,
+    );
+
+    const mutation = useMutation({
+        mutationFn: (data: { custom_config: AssistantConfig }) =>
+            assistantApi.update(wsId, id!, data),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["assistant", wsId, id],
+            });
+            queryClient.invalidateQueries({
+                queryKey: ["assistants", wsId],
+            });
+            setSuccess("Assistant configuration saved.");
+            setError("");
+            setTimeout(() => setSuccess(""), 3000);
+        },
+        onError: (err: Error) => {
+            setError(err.message);
+            setSuccess("");
+        },
+    });
+
+    function handleSubmit(e: FormEvent) {
+        e.preventDefault();
+        const phrases = endCallPhrasesText
+            .split(",")
+            .map((s) => s.trim())
+            .filter(Boolean);
+        mutation.mutate({
+            custom_config: { ...config, end_call_phrases: phrases },
+        });
+    }
+
+    if (isLoading) {
+        return (
+            <div className="flex justify-center py-12">
+                <Spinner size="lg" />
+            </div>
+        );
+    }
+
+    if (!assistant) {
+        return (
+            <div className="text-center py-12 text-gray-500">
+                Assistant not found.
+            </div>
+        );
+    }
+
+    return (
+        <div className="max-w-3xl space-y-6">
+            <div className="flex items-center gap-3">
+                <button
+                    onClick={() => navigate("/assistants")}
+                    className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
+                >
+                    <ArrowLeft className="h-5 w-5" />
+                </button>
+                <div>
+                    <h1 className="text-2xl font-bold text-gray-900">
+                        {assistant.name}
+                    </h1>
+                    <p className="text-sm text-gray-500">
+                        Configure assistant behavior and voice settings.
+                    </p>
+                </div>
+            </div>
+
+            <form onSubmit={handleSubmit} className="space-y-6">
+                {success && (
+                    <div className="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700">
+                        {success}
+                    </div>
+                )}
+                {error && (
+                    <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                        {error}
+                    </div>
+                )}
+
+                {/* Company Info */}
+                <Card title="Company Information">
+                    <div className="space-y-4">
+                        <Input
+                            label="Company Name"
+                            value={config.company_name}
+                            onChange={(e) =>
+                                setConfig({
+                                    ...config,
+                                    company_name: e.target.value,
+                                })
+                            }
+                        />
+
+                        <div>
+                            <label className="block text-sm font-medium text-gray-700 mb-1">
+                                Company Description
+                            </label>
+                            <textarea
+                                value={config.company_description}
+                                onChange={(e) =>
+                                    setConfig({
+                                        ...config,
+                                        company_description: e.target.value,
+                                    })
+                                }
+                                rows={4}
+                                className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 placeholder:text-gray-400"
+                                placeholder="Describe what your company does..."
+                            />
+                        </div>
+
+                        <Input
+                            label="Company Address"
+                            value={config.company_address}
+                            onChange={(e) =>
+                                setConfig({
+                                    ...config,
+                                    company_address: e.target.value,
+                                })
+                            }
+                            placeholder="123 Main St, City, State"
+                        />
+
+                        <Input
+                            label="Company Phone"
+                            value={config.company_phone}
+                            onChange={(e) =>
+                                setConfig({
+                                    ...config,
+                                    company_phone: e.target.value,
+                                })
+                            }
+                            placeholder="+1 (555) 000-0000"
+                        />
+                    </div>
+                </Card>
+
+                {/* Call Settings */}
+                <Card title="Call Settings">
+                    <div className="space-y-4">
+                        <div>
+                            <label className="block text-sm font-medium text-gray-700 mb-1">
+                                Greeting Message
+                            </label>
+                            <textarea
+                                value={config.greeting_message}
+                                onChange={(e) =>
+                                    setConfig({
+                                        ...config,
+                                        greeting_message: e.target.value,
+                                    })
+                                }
+                                rows={3}
+                                className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 placeholder:text-gray-400"
+                                placeholder="Hello! Thank you for calling..."
+                            />
+                        </div>
+
+                        <Input
+                            label="End Call Phrases (comma-separated)"
+                            value={endCallPhrasesText}
+                            onChange={(e) =>
+                                setEndCallPhrasesText(e.target.value)
+                            }
+                            placeholder="goodbye, bye, end call, hang up"
+                        />
+                    </div>
+                </Card>
+
+                {/* Voice Settings */}
+                <Card title="Voice Settings">
+                    <div className="space-y-4">
+                        <div>
+                            <label className="block text-sm font-medium text-gray-700 mb-1">
+                                Voice Provider
+                            </label>
+                            <select
+                                value={config.voice_provider}
+                                onChange={(e) =>
+                                    setConfig({
+                                        ...config,
+                                        voice_provider: e.target.value,
+                                        voice_id: "",
+                                    })
+                                }
+                                className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                            >
+                                <option value="">Select a provider</option>
+                                {providers?.map((p) => (
+                                    <option key={p.provider} value={p.provider}>
+                                        {p.display_name}
+                                    </option>
+                                ))}
+                            </select>
+                        </div>
+
+                        {selectedProvider && (
+                            <div>
+                                <label className="block text-sm font-medium text-gray-700 mb-1">
+                                    Voice
+                                </label>
+                                <select
+                                    value={config.voice_id}
+                                    onChange={(e) =>
+                                        setConfig({
+                                            ...config,
+                                            voice_id: e.target.value,
+                                        })
+                                    }
+                                    className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                                >
+                                    <option value="">Select a voice</option>
+                                    {selectedProvider.voices.map((v) => (
+                                        <option
+                                            key={v.voice_id}
+                                            value={v.voice_id}
+                                        >
+                                            {v.name} ({v.gender} -{" "}
+                                            {v.language})
+                                        </option>
+                                    ))}
+                                </select>
+                            </div>
+                        )}
+                    </div>
+                </Card>
+
+                <div className="flex justify-end">
+                    <Button type="submit" loading={mutation.isPending}>
+                        Save Configuration
+                    </Button>
+                </div>
+            </form>
+        </div>
+    );
+}

+ 240 - 0
webui/src/pages/assistants/AssistantsList.tsx

@@ -0,0 +1,240 @@
+import { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { Link } from "react-router-dom";
+import { Plus, Phone, Settings, Trash2 } from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { Badge } from "@/components/ui/Badge";
+import { Modal } from "@/components/ui/Modal";
+import { Spinner } from "@/components/ui/Spinner";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { assistantApi, calleraiApi } from "@/api/client";
+
+const statusVariant: Record<string, "success" | "warning" | "danger"> = {
+    active: "success",
+    inactive: "warning",
+    error: "danger",
+};
+
+export function AssistantsList() {
+    const queryClient = useQueryClient();
+    const workspace = useWorkspaceStore((s) => s.activeWorkspace);
+    const wsId = workspace?.id ?? "";
+
+    const [createOpen, setCreateOpen] = useState(false);
+    const [newName, setNewName] = useState("");
+    const [selectedPhone, setSelectedPhone] = useState("");
+    const [createError, setCreateError] = useState("");
+
+    const { data: assistants, isLoading } = useQuery({
+        queryKey: ["assistants", wsId],
+        queryFn: () => assistantApi.list(wsId),
+        enabled: !!wsId,
+    });
+
+    const { data: phoneNumbers, isLoading: loadingPhones } = useQuery({
+        queryKey: ["phone-numbers"],
+        queryFn: () => calleraiApi.phoneNumbers(),
+        enabled: createOpen,
+    });
+
+    const createMutation = useMutation({
+        mutationFn: (data: { name: string; phone_number_id: string }) =>
+            assistantApi.create(wsId, data),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["assistants", wsId],
+            });
+            setCreateOpen(false);
+            setNewName("");
+            setSelectedPhone("");
+            setCreateError("");
+        },
+        onError: (err: Error) => {
+            setCreateError(err.message);
+        },
+    });
+
+    const deleteMutation = useMutation({
+        mutationFn: (id: string) => assistantApi.delete(wsId, id),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["assistants", wsId],
+            });
+        },
+    });
+
+    const availablePhones = phoneNumbers?.filter((p) => !p.is_assigned) ?? [];
+
+    if (!workspace) {
+        return (
+            <div className="text-center py-12 text-gray-500">
+                No workspace selected.
+            </div>
+        );
+    }
+
+    return (
+        <div className="space-y-6">
+            <div className="flex items-center justify-between">
+                <div>
+                    <h1 className="text-2xl font-bold text-gray-900">
+                        Assistants
+                    </h1>
+                    <p className="mt-1 text-sm text-gray-500">
+                        Manage your AI phone assistants.
+                    </p>
+                </div>
+                <Button onClick={() => setCreateOpen(true)}>
+                    <Plus className="h-4 w-4" />
+                    Create Assistant
+                </Button>
+            </div>
+
+            {isLoading ? (
+                <div className="flex justify-center py-12">
+                    <Spinner />
+                </div>
+            ) : assistants && assistants.length > 0 ? (
+                <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+                    {assistants.map((assistant) => (
+                        <Card key={assistant.id}>
+                            <div className="space-y-3">
+                                <div className="flex items-start justify-between">
+                                    <div>
+                                        <h3 className="font-semibold text-gray-900">
+                                            {assistant.name}
+                                        </h3>
+                                        <div className="mt-1 flex items-center gap-1.5 text-sm text-gray-500">
+                                            <Phone className="h-3.5 w-3.5" />
+                                            {assistant.phone_number}
+                                        </div>
+                                    </div>
+                                    <Badge
+                                        variant={
+                                            statusVariant[assistant.status] ??
+                                            "default"
+                                        }
+                                    >
+                                        {assistant.status}
+                                    </Badge>
+                                </div>
+
+                                <div className="flex items-center gap-2 pt-1">
+                                    <Link to={`/assistants/${assistant.id}`}>
+                                        <Button variant="secondary" size="sm">
+                                            <Settings className="h-4 w-4" />
+                                            Configure
+                                        </Button>
+                                    </Link>
+                                    <Button
+                                        variant="ghost"
+                                        size="sm"
+                                        onClick={() =>
+                                            deleteMutation.mutate(assistant.id)
+                                        }
+                                        loading={deleteMutation.isPending}
+                                    >
+                                        <Trash2 className="h-4 w-4 text-red-500" />
+                                    </Button>
+                                </div>
+                            </div>
+                        </Card>
+                    ))}
+                </div>
+            ) : (
+                <Card>
+                    <div className="py-8 text-center text-gray-500">
+                        <p>No assistants created yet.</p>
+                        <p className="text-sm mt-1">
+                            Create your first AI assistant to get started.
+                        </p>
+                    </div>
+                </Card>
+            )}
+
+            {/* Create Modal */}
+            <Modal
+                open={createOpen}
+                onClose={() => setCreateOpen(false)}
+                title="Create Assistant"
+            >
+                <form
+                    onSubmit={(e) => {
+                        e.preventDefault();
+                        createMutation.mutate({
+                            name: newName,
+                            phone_number_id: selectedPhone,
+                        });
+                    }}
+                    className="space-y-4"
+                >
+                    {createError && (
+                        <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                            {createError}
+                        </div>
+                    )}
+
+                    <Input
+                        label="Assistant Name"
+                        value={newName}
+                        onChange={(e) => setNewName(e.target.value)}
+                        placeholder="My Assistant"
+                        required
+                    />
+
+                    <div>
+                        <label className="block text-sm font-medium text-gray-700 mb-1">
+                            Phone Number
+                        </label>
+                        {loadingPhones ? (
+                            <div className="flex items-center gap-2 text-sm text-gray-500">
+                                <Spinner size="sm" />
+                                Loading phone numbers...
+                            </div>
+                        ) : (
+                            <select
+                                value={selectedPhone}
+                                onChange={(e) =>
+                                    setSelectedPhone(e.target.value)
+                                }
+                                required
+                                className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                            >
+                                <option value="">Select a phone number</option>
+                                {availablePhones.map((phone) => (
+                                    <option key={phone.id} value={phone.id}>
+                                        {phone.number} ({phone.provider})
+                                    </option>
+                                ))}
+                            </select>
+                        )}
+                        {availablePhones.length === 0 && !loadingPhones && (
+                            <p className="mt-1 text-xs text-gray-500">
+                                No available phone numbers. All numbers may be
+                                assigned.
+                            </p>
+                        )}
+                    </div>
+
+                    <div className="flex justify-end gap-3 pt-2">
+                        <Button
+                            variant="secondary"
+                            type="button"
+                            onClick={() => setCreateOpen(false)}
+                        >
+                            Cancel
+                        </Button>
+                        <Button
+                            type="submit"
+                            loading={createMutation.isPending}
+                        >
+                            Create
+                        </Button>
+                    </div>
+                </form>
+            </Modal>
+        </div>
+    );
+}

+ 102 - 0
webui/src/pages/auth/LoginPage.tsx

@@ -0,0 +1,102 @@
+import { useState, type FormEvent } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { useAuthStore } from "@/stores/authStore";
+import { Input } from "@/components/ui/Input";
+import { Button } from "@/components/ui/Button";
+import { ApiError } from "@/api/client";
+
+export function LoginPage() {
+    const navigate = useNavigate();
+    const login = useAuthStore((s) => s.login);
+
+    const [email, setEmail] = useState("");
+    const [password, setPassword] = useState("");
+    const [error, setError] = useState("");
+    const [loading, setLoading] = useState(false);
+
+    async function handleSubmit(e: FormEvent) {
+        e.preventDefault();
+        setError("");
+        setLoading(true);
+
+        try {
+            await login(email, password);
+            navigate("/", { replace: true });
+        } catch (err) {
+            if (err instanceof ApiError) {
+                setError(err.message);
+            } else {
+                setError("An unexpected error occurred.");
+            }
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+        <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
+            <div className="w-full max-w-sm">
+                {/* Logo / Brand */}
+                <div className="mb-8 text-center">
+                    <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-blue-600 text-white font-bold text-xl">
+                        SB
+                    </div>
+                    <h1 className="text-2xl font-bold text-gray-900">
+                        Welcome back
+                    </h1>
+                    <p className="mt-1 text-sm text-gray-500">
+                        Sign in to Smartbotic Microbit
+                    </p>
+                </div>
+
+                <form
+                    onSubmit={handleSubmit}
+                    className="space-y-4 rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
+                >
+                    {error && (
+                        <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                            {error}
+                        </div>
+                    )}
+
+                    <Input
+                        label="Email"
+                        type="email"
+                        value={email}
+                        onChange={(e) => setEmail(e.target.value)}
+                        placeholder="you@example.com"
+                        required
+                        autoFocus
+                    />
+
+                    <Input
+                        label="Password"
+                        type="password"
+                        value={password}
+                        onChange={(e) => setPassword(e.target.value)}
+                        placeholder="Enter your password"
+                        required
+                    />
+
+                    <Button
+                        type="submit"
+                        loading={loading}
+                        className="w-full"
+                    >
+                        Sign in
+                    </Button>
+                </form>
+
+                <p className="mt-4 text-center text-sm text-gray-500">
+                    Don&apos;t have an account?{" "}
+                    <Link
+                        to="/register"
+                        className="font-medium text-blue-600 hover:text-blue-700"
+                    >
+                        Sign up
+                    </Link>
+                </p>
+            </div>
+        </div>
+    );
+}

+ 124 - 0
webui/src/pages/auth/RegisterPage.tsx

@@ -0,0 +1,124 @@
+import { useState, type FormEvent } from "react";
+import { Link, useNavigate, useParams } from "react-router-dom";
+import { useAuthStore } from "@/stores/authStore";
+import { Input } from "@/components/ui/Input";
+import { Button } from "@/components/ui/Button";
+import { ApiError } from "@/api/client";
+
+export function RegisterPage() {
+    const navigate = useNavigate();
+    const { token } = useParams<{ token?: string }>();
+    const register = useAuthStore((s) => s.register);
+
+    const [email, setEmail] = useState("");
+    const [password, setPassword] = useState("");
+    const [displayName, setDisplayName] = useState("");
+    const [error, setError] = useState("");
+    const [loading, setLoading] = useState(false);
+
+    async function handleSubmit(e: FormEvent) {
+        e.preventDefault();
+        setError("");
+        setLoading(true);
+
+        try {
+            await register({
+                email,
+                password,
+                display_name: displayName,
+                invite_token: token,
+            });
+            navigate("/", { replace: true });
+        } catch (err) {
+            if (err instanceof ApiError) {
+                setError(err.message);
+            } else {
+                setError("An unexpected error occurred.");
+            }
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+        <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4">
+            <div className="w-full max-w-sm">
+                {/* Logo / Brand */}
+                <div className="mb-8 text-center">
+                    <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-blue-600 text-white font-bold text-xl">
+                        SB
+                    </div>
+                    <h1 className="text-2xl font-bold text-gray-900">
+                        Create an account
+                    </h1>
+                    <p className="mt-1 text-sm text-gray-500">
+                        Get started with Smartbotic Microbit
+                    </p>
+                    {token && (
+                        <p className="mt-2 rounded-lg bg-blue-50 px-3 py-2 text-sm text-blue-700">
+                            You have been invited to join a workspace.
+                        </p>
+                    )}
+                </div>
+
+                <form
+                    onSubmit={handleSubmit}
+                    className="space-y-4 rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
+                >
+                    {error && (
+                        <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                            {error}
+                        </div>
+                    )}
+
+                    <Input
+                        label="Display Name"
+                        type="text"
+                        value={displayName}
+                        onChange={(e) => setDisplayName(e.target.value)}
+                        placeholder="John Doe"
+                        required
+                        autoFocus
+                    />
+
+                    <Input
+                        label="Email"
+                        type="email"
+                        value={email}
+                        onChange={(e) => setEmail(e.target.value)}
+                        placeholder="you@example.com"
+                        required
+                    />
+
+                    <Input
+                        label="Password"
+                        type="password"
+                        value={password}
+                        onChange={(e) => setPassword(e.target.value)}
+                        placeholder="At least 8 characters"
+                        required
+                        minLength={8}
+                    />
+
+                    <Button
+                        type="submit"
+                        loading={loading}
+                        className="w-full"
+                    >
+                        Create account
+                    </Button>
+                </form>
+
+                <p className="mt-4 text-center text-sm text-gray-500">
+                    Already have an account?{" "}
+                    <Link
+                        to="/login"
+                        className="font-medium text-blue-600 hover:text-blue-700"
+                    >
+                        Sign in
+                    </Link>
+                </p>
+            </div>
+        </div>
+    );
+}

+ 228 - 0
webui/src/pages/calendar/AppointmentsList.tsx

@@ -0,0 +1,228 @@
+import { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { XCircle, Filter } from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Button } from "@/components/ui/Button";
+import { Badge } from "@/components/ui/Badge";
+import { Spinner } from "@/components/ui/Spinner";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { appointmentApi } from "@/api/client";
+
+const statusVariant: Record<
+    string,
+    "success" | "warning" | "danger" | "info" | "default"
+> = {
+    scheduled: "info",
+    completed: "success",
+    cancelled: "danger",
+    no_show: "warning",
+};
+
+export function AppointmentsList() {
+    const queryClient = useQueryClient();
+    const workspace = useWorkspaceStore((s) => s.activeWorkspace);
+    const wsId = workspace?.id ?? "";
+
+    const [fromDate, setFromDate] = useState("");
+    const [toDate, setToDate] = useState("");
+    const [statusFilter, setStatusFilter] = useState("");
+
+    const { data: appointments, isLoading } = useQuery({
+        queryKey: ["appointments", wsId, fromDate, toDate, statusFilter],
+        queryFn: () =>
+            appointmentApi.list(wsId, {
+                from: fromDate || undefined,
+                to: toDate || undefined,
+                status: statusFilter || undefined,
+            }),
+        enabled: !!wsId,
+    });
+
+    const cancelMutation = useMutation({
+        mutationFn: (aptId: string) => appointmentApi.cancel(wsId, aptId),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["appointments", wsId],
+            });
+        },
+    });
+
+    if (!workspace) {
+        return (
+            <div className="text-center py-12 text-gray-500">
+                No workspace selected.
+            </div>
+        );
+    }
+
+    return (
+        <div className="space-y-6">
+            <div>
+                <h1 className="text-2xl font-bold text-gray-900">
+                    Appointments
+                </h1>
+                <p className="mt-1 text-sm text-gray-500">
+                    View and manage scheduled appointments.
+                </p>
+            </div>
+
+            {/* Filters */}
+            <Card>
+                <div className="flex flex-wrap items-end gap-4">
+                    <div className="flex items-center gap-2 text-sm font-medium text-gray-700">
+                        <Filter className="h-4 w-4" />
+                        Filters
+                    </div>
+                    <div>
+                        <label className="block text-xs text-gray-500 mb-1">
+                            From
+                        </label>
+                        <input
+                            type="date"
+                            value={fromDate}
+                            onChange={(e) => setFromDate(e.target.value)}
+                            className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                        />
+                    </div>
+                    <div>
+                        <label className="block text-xs text-gray-500 mb-1">
+                            To
+                        </label>
+                        <input
+                            type="date"
+                            value={toDate}
+                            onChange={(e) => setToDate(e.target.value)}
+                            className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                        />
+                    </div>
+                    <div>
+                        <label className="block text-xs text-gray-500 mb-1">
+                            Status
+                        </label>
+                        <select
+                            value={statusFilter}
+                            onChange={(e) => setStatusFilter(e.target.value)}
+                            className="rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                        >
+                            <option value="">All</option>
+                            <option value="scheduled">Scheduled</option>
+                            <option value="completed">Completed</option>
+                            <option value="cancelled">Cancelled</option>
+                            <option value="no_show">No Show</option>
+                        </select>
+                    </div>
+                    {(fromDate || toDate || statusFilter) && (
+                        <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => {
+                                setFromDate("");
+                                setToDate("");
+                                setStatusFilter("");
+                            }}
+                        >
+                            Clear
+                        </Button>
+                    )}
+                </div>
+            </Card>
+
+            {/* Table */}
+            <Card padding={false}>
+                {isLoading ? (
+                    <div className="flex justify-center py-12">
+                        <Spinner />
+                    </div>
+                ) : appointments && appointments.length > 0 ? (
+                    <div className="overflow-x-auto">
+                        <table className="w-full text-sm">
+                            <thead>
+                                <tr className="border-b border-gray-200 bg-gray-50">
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Date
+                                    </th>
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Time
+                                    </th>
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Caller
+                                    </th>
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Phone
+                                    </th>
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Status
+                                    </th>
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Notes
+                                    </th>
+                                    <th className="px-6 py-3 text-right font-medium text-gray-500">
+                                        Actions
+                                    </th>
+                                </tr>
+                            </thead>
+                            <tbody className="divide-y divide-gray-100">
+                                {appointments.map((apt) => (
+                                    <tr
+                                        key={apt.id}
+                                        className="hover:bg-gray-50 transition-colors"
+                                    >
+                                        <td className="px-6 py-4 font-medium text-gray-900">
+                                            {apt.date}
+                                        </td>
+                                        <td className="px-6 py-4 text-gray-600">
+                                            {apt.start_time} - {apt.end_time}
+                                        </td>
+                                        <td className="px-6 py-4 text-gray-900">
+                                            {apt.caller_name || "Unknown"}
+                                        </td>
+                                        <td className="px-6 py-4 text-gray-500">
+                                            {apt.caller_phone}
+                                        </td>
+                                        <td className="px-6 py-4">
+                                            <Badge
+                                                variant={
+                                                    statusVariant[
+                                                        apt.status
+                                                    ] ?? "default"
+                                                }
+                                            >
+                                                {apt.status}
+                                            </Badge>
+                                        </td>
+                                        <td className="px-6 py-4 text-gray-500 max-w-xs truncate">
+                                            {apt.notes || "-"}
+                                        </td>
+                                        <td className="px-6 py-4 text-right">
+                                            {apt.status === "scheduled" && (
+                                                <Button
+                                                    variant="ghost"
+                                                    size="sm"
+                                                    onClick={() =>
+                                                        cancelMutation.mutate(
+                                                            apt.id,
+                                                        )
+                                                    }
+                                                    loading={
+                                                        cancelMutation.isPending
+                                                    }
+                                                >
+                                                    <XCircle className="h-4 w-4 text-red-500" />
+                                                    Cancel
+                                                </Button>
+                                            )}
+                                        </td>
+                                    </tr>
+                                ))}
+                            </tbody>
+                        </table>
+                    </div>
+                ) : (
+                    <div className="py-12 text-center text-gray-500">
+                        No appointments found.
+                    </div>
+                )}
+            </Card>
+        </div>
+    );
+}

+ 225 - 0
webui/src/pages/calendar/CalendarManager.tsx

@@ -0,0 +1,225 @@
+import { useState, type FormEvent } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { Link } from "react-router-dom";
+import { Plus, Clock, Trash2, Globe } from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { Modal } from "@/components/ui/Modal";
+import { Spinner } from "@/components/ui/Spinner";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { calendarApi, assistantApi } from "@/api/client";
+
+export function CalendarManager() {
+    const queryClient = useQueryClient();
+    const workspace = useWorkspaceStore((s) => s.activeWorkspace);
+    const wsId = workspace?.id ?? "";
+
+    const [createOpen, setCreateOpen] = useState(false);
+    const [newName, setNewName] = useState("");
+    const [newTimezone, setNewTimezone] = useState(
+        Intl.DateTimeFormat().resolvedOptions().timeZone,
+    );
+    const [newAssistantId, setNewAssistantId] = useState("");
+    const [createError, setCreateError] = useState("");
+
+    const { data: calendars, isLoading } = useQuery({
+        queryKey: ["calendars", wsId],
+        queryFn: () => calendarApi.list(wsId),
+        enabled: !!wsId,
+    });
+
+    const { data: assistants } = useQuery({
+        queryKey: ["assistants", wsId],
+        queryFn: () => assistantApi.list(wsId),
+        enabled: !!wsId && createOpen,
+    });
+
+    const createMutation = useMutation({
+        mutationFn: (data: {
+            name: string;
+            timezone: string;
+            assistant_id: string;
+        }) => calendarApi.create(wsId, data),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["calendars", wsId],
+            });
+            setCreateOpen(false);
+            setNewName("");
+            setNewAssistantId("");
+            setCreateError("");
+        },
+        onError: (err: Error) => {
+            setCreateError(err.message);
+        },
+    });
+
+    const deleteMutation = useMutation({
+        mutationFn: (calId: string) => calendarApi.delete(wsId, calId),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["calendars", wsId],
+            });
+        },
+    });
+
+    function handleCreate(e: FormEvent) {
+        e.preventDefault();
+        createMutation.mutate({
+            name: newName,
+            timezone: newTimezone,
+            assistant_id: newAssistantId,
+        });
+    }
+
+    if (!workspace) {
+        return (
+            <div className="text-center py-12 text-gray-500">
+                No workspace selected.
+            </div>
+        );
+    }
+
+    return (
+        <div className="space-y-6">
+            <div className="flex items-center justify-between">
+                <div>
+                    <h1 className="text-2xl font-bold text-gray-900">
+                        Calendars
+                    </h1>
+                    <p className="mt-1 text-sm text-gray-500">
+                        Manage availability calendars for your assistants.
+                    </p>
+                </div>
+                <Button onClick={() => setCreateOpen(true)}>
+                    <Plus className="h-4 w-4" />
+                    Create Calendar
+                </Button>
+            </div>
+
+            {isLoading ? (
+                <div className="flex justify-center py-12">
+                    <Spinner />
+                </div>
+            ) : calendars && calendars.length > 0 ? (
+                <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+                    {calendars.map((cal) => (
+                        <Card key={cal.id}>
+                            <div className="space-y-3">
+                                <div>
+                                    <h3 className="font-semibold text-gray-900">
+                                        {cal.name}
+                                    </h3>
+                                    <div className="mt-1 flex items-center gap-1.5 text-sm text-gray-500">
+                                        <Globe className="h-3.5 w-3.5" />
+                                        {cal.timezone}
+                                    </div>
+                                </div>
+
+                                <div className="flex items-center gap-2 pt-1">
+                                    <Link
+                                        to={`/calendars/${cal.id}/time-slots`}
+                                    >
+                                        <Button variant="secondary" size="sm">
+                                            <Clock className="h-4 w-4" />
+                                            Time Slots
+                                        </Button>
+                                    </Link>
+                                    <Button
+                                        variant="ghost"
+                                        size="sm"
+                                        onClick={() =>
+                                            deleteMutation.mutate(cal.id)
+                                        }
+                                        loading={deleteMutation.isPending}
+                                    >
+                                        <Trash2 className="h-4 w-4 text-red-500" />
+                                    </Button>
+                                </div>
+                            </div>
+                        </Card>
+                    ))}
+                </div>
+            ) : (
+                <Card>
+                    <div className="py-8 text-center text-gray-500">
+                        <p>No calendars created yet.</p>
+                        <p className="text-sm mt-1">
+                            Create a calendar to define availability for
+                            appointments.
+                        </p>
+                    </div>
+                </Card>
+            )}
+
+            {/* Create Modal */}
+            <Modal
+                open={createOpen}
+                onClose={() => setCreateOpen(false)}
+                title="Create Calendar"
+            >
+                <form onSubmit={handleCreate} className="space-y-4">
+                    {createError && (
+                        <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                            {createError}
+                        </div>
+                    )}
+
+                    <Input
+                        label="Calendar Name"
+                        value={newName}
+                        onChange={(e) => setNewName(e.target.value)}
+                        placeholder="Business Hours"
+                        required
+                    />
+
+                    <Input
+                        label="Timezone"
+                        value={newTimezone}
+                        onChange={(e) => setNewTimezone(e.target.value)}
+                        placeholder="America/New_York"
+                        required
+                    />
+
+                    <div>
+                        <label className="block text-sm font-medium text-gray-700 mb-1">
+                            Assistant
+                        </label>
+                        <select
+                            value={newAssistantId}
+                            onChange={(e) =>
+                                setNewAssistantId(e.target.value)
+                            }
+                            required
+                            className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                        >
+                            <option value="">Select an assistant</option>
+                            {assistants?.map((a) => (
+                                <option key={a.id} value={a.id}>
+                                    {a.name}
+                                </option>
+                            ))}
+                        </select>
+                    </div>
+
+                    <div className="flex justify-end gap-3 pt-2">
+                        <Button
+                            variant="secondary"
+                            type="button"
+                            onClick={() => setCreateOpen(false)}
+                        >
+                            Cancel
+                        </Button>
+                        <Button
+                            type="submit"
+                            loading={createMutation.isPending}
+                        >
+                            Create
+                        </Button>
+                    </div>
+                </form>
+            </Modal>
+        </div>
+    );
+}

+ 304 - 0
webui/src/pages/calendar/TimeSlotsEditor.tsx

@@ -0,0 +1,304 @@
+import { useState, type FormEvent } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { ArrowLeft, Plus, Pencil, Trash2 } from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Button } from "@/components/ui/Button";
+import { Modal } from "@/components/ui/Modal";
+import { Spinner } from "@/components/ui/Spinner";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { timeSlotApi, calendarApi } from "@/api/client";
+import type { TimeSlot } from "@/types";
+
+const DAYS = [
+    "Sunday",
+    "Monday",
+    "Tuesday",
+    "Wednesday",
+    "Thursday",
+    "Friday",
+    "Saturday",
+];
+
+const DAY_COLORS = [
+    "bg-red-50 border-red-200",
+    "bg-blue-50 border-blue-200",
+    "bg-green-50 border-green-200",
+    "bg-yellow-50 border-yellow-200",
+    "bg-purple-50 border-purple-200",
+    "bg-pink-50 border-pink-200",
+    "bg-orange-50 border-orange-200",
+];
+
+export function TimeSlotsEditor() {
+    const { calId } = useParams<{ calId: string }>();
+    const navigate = useNavigate();
+    const queryClient = useQueryClient();
+    const workspace = useWorkspaceStore((s) => s.activeWorkspace);
+    const wsId = workspace?.id ?? "";
+
+    const [modalOpen, setModalOpen] = useState(false);
+    const [editingSlot, setEditingSlot] = useState<TimeSlot | null>(null);
+    const [dayOfWeek, setDayOfWeek] = useState(1);
+    const [startTime, setStartTime] = useState("09:00");
+    const [endTime, setEndTime] = useState("17:00");
+    const [formError, setFormError] = useState("");
+
+    const { data: calendar } = useQuery({
+        queryKey: ["calendar", wsId, calId],
+        queryFn: () => calendarApi.get(wsId, calId!),
+        enabled: !!wsId && !!calId,
+    });
+
+    const { data: slots, isLoading } = useQuery({
+        queryKey: ["time-slots", wsId, calId],
+        queryFn: () => timeSlotApi.list(wsId, calId!),
+        enabled: !!wsId && !!calId,
+    });
+
+    const createMutation = useMutation({
+        mutationFn: (data: Partial<TimeSlot>) =>
+            timeSlotApi.create(wsId, calId!, data),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["time-slots", wsId, calId],
+            });
+            closeModal();
+        },
+        onError: (err: Error) => setFormError(err.message),
+    });
+
+    const updateMutation = useMutation({
+        mutationFn: ({
+            slotId,
+            data,
+        }: {
+            slotId: string;
+            data: Partial<TimeSlot>;
+        }) => timeSlotApi.update(wsId, calId!, slotId, data),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["time-slots", wsId, calId],
+            });
+            closeModal();
+        },
+        onError: (err: Error) => setFormError(err.message),
+    });
+
+    const deleteMutation = useMutation({
+        mutationFn: (slotId: string) =>
+            timeSlotApi.delete(wsId, calId!, slotId),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["time-slots", wsId, calId],
+            });
+        },
+    });
+
+    function openAdd() {
+        setEditingSlot(null);
+        setDayOfWeek(1);
+        setStartTime("09:00");
+        setEndTime("17:00");
+        setFormError("");
+        setModalOpen(true);
+    }
+
+    function openEdit(slot: TimeSlot) {
+        setEditingSlot(slot);
+        setDayOfWeek(slot.day_of_week);
+        setStartTime(slot.start_time);
+        setEndTime(slot.end_time);
+        setFormError("");
+        setModalOpen(true);
+    }
+
+    function closeModal() {
+        setModalOpen(false);
+        setEditingSlot(null);
+        setFormError("");
+    }
+
+    function handleSubmit(e: FormEvent) {
+        e.preventDefault();
+        const data = {
+            day_of_week: dayOfWeek,
+            start_time: startTime,
+            end_time: endTime,
+        };
+        if (editingSlot) {
+            updateMutation.mutate({ slotId: editingSlot.id, data });
+        } else {
+            createMutation.mutate(data);
+        }
+    }
+
+    // Group slots by day
+    const slotsByDay = DAYS.map((_, idx) =>
+        (slots ?? []).filter((s) => s.day_of_week === idx),
+    );
+
+    return (
+        <div className="space-y-6">
+            <div className="flex items-center gap-3">
+                <button
+                    onClick={() => navigate("/calendars")}
+                    className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 transition-colors"
+                >
+                    <ArrowLeft className="h-5 w-5" />
+                </button>
+                <div className="flex-1">
+                    <h1 className="text-2xl font-bold text-gray-900">
+                        Time Slots
+                    </h1>
+                    <p className="text-sm text-gray-500">
+                        {calendar?.name ?? "Calendar"} &mdash;{" "}
+                        {calendar?.timezone ?? ""}
+                    </p>
+                </div>
+                <Button onClick={openAdd}>
+                    <Plus className="h-4 w-4" />
+                    Add Slot
+                </Button>
+            </div>
+
+            {isLoading ? (
+                <div className="flex justify-center py-12">
+                    <Spinner />
+                </div>
+            ) : (
+                <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
+                    {DAYS.map((dayName, idx) => (
+                        <div
+                            key={dayName}
+                            className={`rounded-xl border p-4 ${DAY_COLORS[idx]}`}
+                        >
+                            <h3 className="text-sm font-semibold text-gray-800 mb-3">
+                                {dayName}
+                            </h3>
+                            {slotsByDay[idx]!.length > 0 ? (
+                                <div className="space-y-2">
+                                    {slotsByDay[idx]!.map((slot) => (
+                                        <div
+                                            key={slot.id}
+                                            className="flex items-center justify-between rounded-lg bg-white px-3 py-2 text-sm shadow-sm"
+                                        >
+                                            <span className="font-medium text-gray-700">
+                                                {slot.start_time} -{" "}
+                                                {slot.end_time}
+                                            </span>
+                                            <div className="flex items-center gap-1">
+                                                <button
+                                                    onClick={() =>
+                                                        openEdit(slot)
+                                                    }
+                                                    className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
+                                                >
+                                                    <Pencil className="h-3.5 w-3.5" />
+                                                </button>
+                                                <button
+                                                    onClick={() =>
+                                                        deleteMutation.mutate(
+                                                            slot.id,
+                                                        )
+                                                    }
+                                                    className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500"
+                                                >
+                                                    <Trash2 className="h-3.5 w-3.5" />
+                                                </button>
+                                            </div>
+                                        </div>
+                                    ))}
+                                </div>
+                            ) : (
+                                <p className="text-xs text-gray-400 italic">
+                                    No slots
+                                </p>
+                            )}
+                        </div>
+                    ))}
+                </div>
+            )}
+
+            {/* Add/Edit Modal */}
+            <Modal
+                open={modalOpen}
+                onClose={closeModal}
+                title={editingSlot ? "Edit Time Slot" : "Add Time Slot"}
+            >
+                <form onSubmit={handleSubmit} className="space-y-4">
+                    {formError && (
+                        <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                            {formError}
+                        </div>
+                    )}
+
+                    <div>
+                        <label className="block text-sm font-medium text-gray-700 mb-1">
+                            Day of Week
+                        </label>
+                        <select
+                            value={dayOfWeek}
+                            onChange={(e) =>
+                                setDayOfWeek(Number(e.target.value))
+                            }
+                            className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                        >
+                            {DAYS.map((name, idx) => (
+                                <option key={idx} value={idx}>
+                                    {name}
+                                </option>
+                            ))}
+                        </select>
+                    </div>
+
+                    <div className="grid grid-cols-2 gap-4">
+                        <div>
+                            <label className="block text-sm font-medium text-gray-700 mb-1">
+                                Start Time
+                            </label>
+                            <input
+                                type="time"
+                                value={startTime}
+                                onChange={(e) => setStartTime(e.target.value)}
+                                required
+                                className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                            />
+                        </div>
+                        <div>
+                            <label className="block text-sm font-medium text-gray-700 mb-1">
+                                End Time
+                            </label>
+                            <input
+                                type="time"
+                                value={endTime}
+                                onChange={(e) => setEndTime(e.target.value)}
+                                required
+                                className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                            />
+                        </div>
+                    </div>
+
+                    <div className="flex justify-end gap-3 pt-2">
+                        <Button
+                            variant="secondary"
+                            type="button"
+                            onClick={closeModal}
+                        >
+                            Cancel
+                        </Button>
+                        <Button
+                            type="submit"
+                            loading={
+                                createMutation.isPending ||
+                                updateMutation.isPending
+                            }
+                        >
+                            {editingSlot ? "Update" : "Add Slot"}
+                        </Button>
+                    </div>
+                </form>
+            </Modal>
+        </div>
+    );
+}

+ 154 - 0
webui/src/pages/dashboard/Dashboard.tsx

@@ -0,0 +1,154 @@
+import { useQuery } from "@tanstack/react-query";
+import {
+    Users,
+    Bot,
+    CalendarCheck,
+    Building2,
+} from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Spinner } from "@/components/ui/Spinner";
+import { useAuthStore } from "@/stores/authStore";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { memberApi, assistantApi, appointmentApi } from "@/api/client";
+
+export function Dashboard() {
+    const user = useAuthStore((s) => s.user);
+    const workspace = useWorkspaceStore((s) => s.activeWorkspace);
+    const wsId = workspace?.id ?? "";
+
+    const { data: members, isLoading: loadingMembers } = useQuery({
+        queryKey: ["members", wsId],
+        queryFn: () => memberApi.list(wsId),
+        enabled: !!wsId,
+    });
+
+    const { data: assistants, isLoading: loadingAssistants } = useQuery({
+        queryKey: ["assistants", wsId],
+        queryFn: () => assistantApi.list(wsId),
+        enabled: !!wsId,
+    });
+
+    const { data: appointments, isLoading: loadingAppointments } = useQuery({
+        queryKey: ["appointments", wsId, "upcoming"],
+        queryFn: () =>
+            appointmentApi.list(wsId, {
+                from: new Date().toISOString().split("T")[0],
+                status: "scheduled",
+            }),
+        enabled: !!wsId,
+    });
+
+    const isLoading = loadingMembers || loadingAssistants || loadingAppointments;
+
+    const stats = [
+        {
+            label: "Members",
+            value: members?.length ?? 0,
+            icon: Users,
+            color: "bg-blue-100 text-blue-600",
+        },
+        {
+            label: "Assistants",
+            value: assistants?.length ?? 0,
+            icon: Bot,
+            color: "bg-purple-100 text-purple-600",
+        },
+        {
+            label: "Upcoming Appointments",
+            value: appointments?.length ?? 0,
+            icon: CalendarCheck,
+            color: "bg-green-100 text-green-600",
+        },
+    ];
+
+    if (!workspace) {
+        return (
+            <div className="flex flex-col items-center justify-center py-20">
+                <Building2 className="h-12 w-12 text-gray-300 mb-4" />
+                <h2 className="text-lg font-semibold text-gray-900">
+                    No workspace selected
+                </h2>
+                <p className="mt-1 text-sm text-gray-500">
+                    Select or create a workspace to get started.
+                </p>
+            </div>
+        );
+    }
+
+    return (
+        <div className="space-y-6">
+            {/* Welcome */}
+            <div>
+                <h1 className="text-2xl font-bold text-gray-900">
+                    Welcome back, {user?.display_name?.split(" ")[0] ?? "there"}
+                </h1>
+                <p className="mt-1 text-sm text-gray-500">
+                    Here&apos;s an overview of{" "}
+                    <span className="font-medium text-gray-700">
+                        {workspace.company_name}
+                    </span>
+                </p>
+            </div>
+
+            {/* Stats grid */}
+            {isLoading ? (
+                <div className="flex justify-center py-12">
+                    <Spinner size="lg" />
+                </div>
+            ) : (
+                <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+                    {stats.map((stat) => (
+                        <Card key={stat.label}>
+                            <div className="flex items-center gap-4">
+                                <div
+                                    className={`flex h-12 w-12 items-center justify-center rounded-lg ${stat.color}`}
+                                >
+                                    <stat.icon className="h-6 w-6" />
+                                </div>
+                                <div>
+                                    <p className="text-sm text-gray-500">
+                                        {stat.label}
+                                    </p>
+                                    <p className="text-2xl font-bold text-gray-900">
+                                        {stat.value}
+                                    </p>
+                                </div>
+                            </div>
+                        </Card>
+                    ))}
+                </div>
+            )}
+
+            {/* Recent appointments */}
+            {appointments && appointments.length > 0 && (
+                <Card title="Upcoming Appointments">
+                    <div className="divide-y divide-gray-100">
+                        {appointments.slice(0, 5).map((apt) => (
+                            <div
+                                key={apt.id}
+                                className="flex items-center justify-between py-3"
+                            >
+                                <div>
+                                    <p className="text-sm font-medium text-gray-900">
+                                        {apt.caller_name || "Unknown caller"}
+                                    </p>
+                                    <p className="text-xs text-gray-500">
+                                        {apt.caller_phone}
+                                    </p>
+                                </div>
+                                <div className="text-right">
+                                    <p className="text-sm font-medium text-gray-900">
+                                        {apt.date}
+                                    </p>
+                                    <p className="text-xs text-gray-500">
+                                        {apt.start_time} - {apt.end_time}
+                                    </p>
+                                </div>
+                            </div>
+                        ))}
+                    </div>
+                </Card>
+            )}
+        </div>
+    );
+}

+ 117 - 0
webui/src/pages/invitations/PendingInvitations.tsx

@@ -0,0 +1,117 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { Check, X, Mail } from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Button } from "@/components/ui/Button";
+import { Badge } from "@/components/ui/Badge";
+import { Spinner } from "@/components/ui/Spinner";
+import { invitationApi } from "@/api/client";
+
+export function PendingInvitations() {
+    const queryClient = useQueryClient();
+
+    const { data: invitations, isLoading } = useQuery({
+        queryKey: ["invitations-pending"],
+        queryFn: () => invitationApi.pending(),
+    });
+
+    const acceptMutation = useMutation({
+        mutationFn: (id: string) => invitationApi.accept(id),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["invitations-pending"],
+            });
+            queryClient.invalidateQueries({ queryKey: ["workspaces"] });
+        },
+    });
+
+    const declineMutation = useMutation({
+        mutationFn: (id: string) => invitationApi.decline(id),
+        onSuccess: () => {
+            queryClient.invalidateQueries({
+                queryKey: ["invitations-pending"],
+            });
+        },
+    });
+
+    return (
+        <div className="space-y-6">
+            <div>
+                <h1 className="text-2xl font-bold text-gray-900">
+                    Pending Invitations
+                </h1>
+                <p className="mt-1 text-sm text-gray-500">
+                    Invitations from other workspaces.
+                </p>
+            </div>
+
+            {isLoading ? (
+                <div className="flex justify-center py-12">
+                    <Spinner />
+                </div>
+            ) : invitations && invitations.length > 0 ? (
+                <div className="space-y-3">
+                    {invitations.map((inv) => (
+                        <Card key={inv.id}>
+                            <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
+                                <div className="flex items-start gap-3">
+                                    <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
+                                        <Mail className="h-5 w-5" />
+                                    </div>
+                                    <div>
+                                        <p className="font-medium text-gray-900">
+                                            {inv.workspace_name}
+                                        </p>
+                                        <p className="text-sm text-gray-500">
+                                            Invited as{" "}
+                                            <Badge variant="info">
+                                                {inv.role}
+                                            </Badge>
+                                        </p>
+                                        <p className="text-xs text-gray-400 mt-1">
+                                            Expires{" "}
+                                            {new Date(
+                                                inv.expires_at,
+                                            ).toLocaleDateString()}
+                                        </p>
+                                    </div>
+                                </div>
+
+                                <div className="flex items-center gap-2">
+                                    <Button
+                                        variant="primary"
+                                        size="sm"
+                                        onClick={() =>
+                                            acceptMutation.mutate(inv.id)
+                                        }
+                                        loading={acceptMutation.isPending}
+                                    >
+                                        <Check className="h-4 w-4" />
+                                        Accept
+                                    </Button>
+                                    <Button
+                                        variant="secondary"
+                                        size="sm"
+                                        onClick={() =>
+                                            declineMutation.mutate(inv.id)
+                                        }
+                                        loading={declineMutation.isPending}
+                                    >
+                                        <X className="h-4 w-4" />
+                                        Decline
+                                    </Button>
+                                </div>
+                            </div>
+                        </Card>
+                    ))}
+                </div>
+            ) : (
+                <Card>
+                    <div className="py-8 text-center text-gray-500">
+                        <Mail className="h-10 w-10 mx-auto mb-3 text-gray-300" />
+                        <p>No pending invitations.</p>
+                    </div>
+                </Card>
+            )}
+        </div>
+    );
+}

+ 249 - 0
webui/src/pages/members/MembersList.tsx

@@ -0,0 +1,249 @@
+import { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { UserPlus, Trash2 } from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Button } from "@/components/ui/Button";
+import { Input } from "@/components/ui/Input";
+import { Badge } from "@/components/ui/Badge";
+import { Modal } from "@/components/ui/Modal";
+import { Spinner } from "@/components/ui/Spinner";
+import { useWorkspaceStore } from "@/stores/workspaceStore";
+import { memberApi, invitationApi } from "@/api/client";
+import type { WorkspaceMember } from "@/types";
+
+const roleBadgeVariant: Record<string, "success" | "info" | "default"> = {
+    owner: "success",
+    admin: "info",
+    member: "default",
+};
+
+export function MembersList() {
+    const queryClient = useQueryClient();
+    const workspace = useWorkspaceStore((s) => s.activeWorkspace);
+    const wsId = workspace?.id ?? "";
+
+    const [inviteOpen, setInviteOpen] = useState(false);
+    const [inviteEmail, setInviteEmail] = useState("");
+    const [inviteRole, setInviteRole] = useState("member");
+    const [inviteError, setInviteError] = useState("");
+
+    const { data: members, isLoading } = useQuery({
+        queryKey: ["members", wsId],
+        queryFn: () => memberApi.list(wsId),
+        enabled: !!wsId,
+    });
+
+    const removeMutation = useMutation({
+        mutationFn: (memberId: string) => memberApi.remove(wsId, memberId),
+        onSuccess: () => {
+            queryClient.invalidateQueries({ queryKey: ["members", wsId] });
+        },
+    });
+
+    const roleMutation = useMutation({
+        mutationFn: ({ memberId, role }: { memberId: string; role: string }) =>
+            memberApi.updateRole(wsId, memberId, role),
+        onSuccess: () => {
+            queryClient.invalidateQueries({ queryKey: ["members", wsId] });
+        },
+    });
+
+    const inviteMutation = useMutation({
+        mutationFn: (data: { email: string; role: string }) =>
+            invitationApi.create(wsId, data),
+        onSuccess: () => {
+            setInviteOpen(false);
+            setInviteEmail("");
+            setInviteRole("member");
+            setInviteError("");
+        },
+        onError: (err: Error) => {
+            setInviteError(err.message);
+        },
+    });
+
+    function handleRoleChange(member: WorkspaceMember, newRole: string) {
+        roleMutation.mutate({ memberId: member.id, role: newRole });
+    }
+
+    if (!workspace) {
+        return (
+            <div className="text-center py-12 text-gray-500">
+                No workspace selected.
+            </div>
+        );
+    }
+
+    return (
+        <div className="space-y-6">
+            <div className="flex items-center justify-between">
+                <div>
+                    <h1 className="text-2xl font-bold text-gray-900">
+                        Members
+                    </h1>
+                    <p className="mt-1 text-sm text-gray-500">
+                        Manage workspace members and their roles.
+                    </p>
+                </div>
+                <Button onClick={() => setInviteOpen(true)}>
+                    <UserPlus className="h-4 w-4" />
+                    Invite Member
+                </Button>
+            </div>
+
+            <Card padding={false}>
+                {isLoading ? (
+                    <div className="flex justify-center py-12">
+                        <Spinner />
+                    </div>
+                ) : members && members.length > 0 ? (
+                    <div className="overflow-x-auto">
+                        <table className="w-full text-sm">
+                            <thead>
+                                <tr className="border-b border-gray-200 bg-gray-50">
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Name
+                                    </th>
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Email
+                                    </th>
+                                    <th className="px-6 py-3 text-left font-medium text-gray-500">
+                                        Role
+                                    </th>
+                                    <th className="px-6 py-3 text-right font-medium text-gray-500">
+                                        Actions
+                                    </th>
+                                </tr>
+                            </thead>
+                            <tbody className="divide-y divide-gray-100">
+                                {members.map((member) => (
+                                    <tr
+                                        key={member.id}
+                                        className="hover:bg-gray-50 transition-colors"
+                                    >
+                                        <td className="px-6 py-4 font-medium text-gray-900">
+                                            {member.display_name}
+                                        </td>
+                                        <td className="px-6 py-4 text-gray-500">
+                                            {member.email}
+                                        </td>
+                                        <td className="px-6 py-4">
+                                            {member.role === "owner" ? (
+                                                <Badge variant={roleBadgeVariant[member.role]}>
+                                                    {member.role}
+                                                </Badge>
+                                            ) : (
+                                                <select
+                                                    value={member.role}
+                                                    onChange={(e) =>
+                                                        handleRoleChange(
+                                                            member,
+                                                            e.target.value,
+                                                        )
+                                                    }
+                                                    className="rounded-lg border border-gray-300 bg-white px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+                                                >
+                                                    <option value="admin">
+                                                        admin
+                                                    </option>
+                                                    <option value="member">
+                                                        member
+                                                    </option>
+                                                </select>
+                                            )}
+                                        </td>
+                                        <td className="px-6 py-4 text-right">
+                                            {member.role !== "owner" && (
+                                                <Button
+                                                    variant="ghost"
+                                                    size="sm"
+                                                    onClick={() =>
+                                                        removeMutation.mutate(
+                                                            member.id,
+                                                        )
+                                                    }
+                                                    loading={
+                                                        removeMutation.isPending
+                                                    }
+                                                >
+                                                    <Trash2 className="h-4 w-4 text-red-500" />
+                                                </Button>
+                                            )}
+                                        </td>
+                                    </tr>
+                                ))}
+                            </tbody>
+                        </table>
+                    </div>
+                ) : (
+                    <div className="py-12 text-center text-gray-500">
+                        No members found.
+                    </div>
+                )}
+            </Card>
+
+            {/* Invite Modal */}
+            <Modal
+                open={inviteOpen}
+                onClose={() => setInviteOpen(false)}
+                title="Invite Member"
+            >
+                <form
+                    onSubmit={(e) => {
+                        e.preventDefault();
+                        inviteMutation.mutate({
+                            email: inviteEmail,
+                            role: inviteRole,
+                        });
+                    }}
+                    className="space-y-4"
+                >
+                    {inviteError && (
+                        <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                            {inviteError}
+                        </div>
+                    )}
+
+                    <Input
+                        label="Email Address"
+                        type="email"
+                        value={inviteEmail}
+                        onChange={(e) => setInviteEmail(e.target.value)}
+                        placeholder="colleague@example.com"
+                        required
+                    />
+
+                    <div>
+                        <label className="block text-sm font-medium text-gray-700 mb-1">
+                            Role
+                        </label>
+                        <select
+                            value={inviteRole}
+                            onChange={(e) => setInviteRole(e.target.value)}
+                            className="block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
+                        >
+                            <option value="member">Member</option>
+                            <option value="admin">Admin</option>
+                        </select>
+                    </div>
+
+                    <div className="flex justify-end gap-3 pt-2">
+                        <Button
+                            variant="secondary"
+                            type="button"
+                            onClick={() => setInviteOpen(false)}
+                        >
+                            Cancel
+                        </Button>
+                        <Button
+                            type="submit"
+                            loading={inviteMutation.isPending}
+                        >
+                            Send Invitation
+                        </Button>
+                    </div>
+                </form>
+            </Modal>
+        </div>
+    );
+}

+ 323 - 0
webui/src/pages/settings/GlobalSettings.tsx

@@ -0,0 +1,323 @@
+import { useState, useEffect, type FormEvent } from "react";
+import { useQuery, useMutation } from "@tanstack/react-query";
+import { Send, Wifi } from "lucide-react";
+import { Card } from "@/components/ui/Card";
+import { Input } from "@/components/ui/Input";
+import { Button } from "@/components/ui/Button";
+import { Spinner } from "@/components/ui/Spinner";
+import { useAuthStore } from "@/stores/authStore";
+import { settingsApi } from "@/api/client";
+import type { SmtpSettings, CallerAiSettings } from "@/types";
+
+export function GlobalSettings() {
+    const user = useAuthStore((s) => s.user);
+
+    const [smtp, setSmtp] = useState<SmtpSettings>({
+        host: "",
+        port: 587,
+        username: "",
+        password: "",
+        from_address: "",
+        from_name: "",
+    });
+
+    const [callerai, setCallerai] = useState<CallerAiSettings>({
+        api_url: "",
+        api_key: "",
+    });
+
+    const [smtpSuccess, setSmtpSuccess] = useState("");
+    const [smtpError, setSmtpError] = useState("");
+    const [calleraiSuccess, setCalleraiSuccess] = useState("");
+    const [calleraiError, setCalleraiError] = useState("");
+
+    const { data: settings, isLoading } = useQuery({
+        queryKey: ["settings"],
+        queryFn: () => settingsApi.get(),
+        enabled: !!user?.is_owner,
+    });
+
+    useEffect(() => {
+        if (settings) {
+            if (settings.smtp) setSmtp(settings.smtp);
+            if (settings.callerai) setCallerai(settings.callerai);
+        }
+    }, [settings]);
+
+    const smtpMutation = useMutation({
+        mutationFn: (data: SmtpSettings) => settingsApi.updateSmtp(data),
+        onSuccess: () => {
+            setSmtpSuccess("SMTP settings saved.");
+            setSmtpError("");
+            setTimeout(() => setSmtpSuccess(""), 3000);
+        },
+        onError: (err: Error) => {
+            setSmtpError(err.message);
+            setSmtpSuccess("");
+        },
+    });
+
+    const smtpTestMutation = useMutation({
+        mutationFn: () => settingsApi.testSmtp(),
+        onSuccess: (result) => {
+            if (result.success) {
+                setSmtpSuccess(result.message || "SMTP test successful!");
+                setSmtpError("");
+            } else {
+                setSmtpError(result.message || "SMTP test failed.");
+                setSmtpSuccess("");
+            }
+            setTimeout(() => {
+                setSmtpSuccess("");
+                setSmtpError("");
+            }, 5000);
+        },
+        onError: (err: Error) => {
+            setSmtpError(err.message);
+            setSmtpSuccess("");
+        },
+    });
+
+    const calleraiMutation = useMutation({
+        mutationFn: (data: CallerAiSettings) =>
+            settingsApi.updateCallerAi(data),
+        onSuccess: () => {
+            setCalleraiSuccess("CallerAI settings saved.");
+            setCalleraiError("");
+            setTimeout(() => setCalleraiSuccess(""), 3000);
+        },
+        onError: (err: Error) => {
+            setCalleraiError(err.message);
+            setCalleraiSuccess("");
+        },
+    });
+
+    const calleraiTestMutation = useMutation({
+        mutationFn: () => settingsApi.testCallerAi(),
+        onSuccess: (result) => {
+            if (result.success) {
+                setCalleraiSuccess(
+                    result.message || "CallerAI test successful!",
+                );
+                setCalleraiError("");
+            } else {
+                setCalleraiError(result.message || "CallerAI test failed.");
+                setCalleraiSuccess("");
+            }
+            setTimeout(() => {
+                setCalleraiSuccess("");
+                setCalleraiError("");
+            }, 5000);
+        },
+        onError: (err: Error) => {
+            setCalleraiError(err.message);
+            setCalleraiSuccess("");
+        },
+    });
+
+    function handleSmtpSave(e: FormEvent) {
+        e.preventDefault();
+        smtpMutation.mutate(smtp);
+    }
+
+    function handleCalleraiSave(e: FormEvent) {
+        e.preventDefault();
+        calleraiMutation.mutate(callerai);
+    }
+
+    if (!user?.is_owner) {
+        return (
+            <div className="text-center py-12">
+                <p className="text-lg font-semibold text-gray-900">
+                    Access Denied
+                </p>
+                <p className="mt-1 text-sm text-gray-500">
+                    Only the system owner can access global settings.
+                </p>
+            </div>
+        );
+    }
+
+    if (isLoading) {
+        return (
+            <div className="flex justify-center py-12">
+                <Spinner size="lg" />
+            </div>
+        );
+    }
+
+    return (
+        <div className="max-w-2xl space-y-6">
+            <div>
+                <h1 className="text-2xl font-bold text-gray-900">
+                    Global Settings
+                </h1>
+                <p className="mt-1 text-sm text-gray-500">
+                    Configure system-wide integrations (owner only).
+                </p>
+            </div>
+
+            {/* SMTP Settings */}
+            <Card title="SMTP Configuration">
+                <form onSubmit={handleSmtpSave} className="space-y-4">
+                    {smtpSuccess && (
+                        <div className="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700">
+                            {smtpSuccess}
+                        </div>
+                    )}
+                    {smtpError && (
+                        <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                            {smtpError}
+                        </div>
+                    )}
+
+                    <div className="grid grid-cols-2 gap-4">
+                        <Input
+                            label="SMTP Host"
+                            value={smtp.host}
+                            onChange={(e) =>
+                                setSmtp({ ...smtp, host: e.target.value })
+                            }
+                            placeholder="smtp.example.com"
+                        />
+                        <Input
+                            label="Port"
+                            type="number"
+                            value={String(smtp.port)}
+                            onChange={(e) =>
+                                setSmtp({
+                                    ...smtp,
+                                    port: Number(e.target.value),
+                                })
+                            }
+                            placeholder="587"
+                        />
+                    </div>
+
+                    <div className="grid grid-cols-2 gap-4">
+                        <Input
+                            label="Username"
+                            value={smtp.username}
+                            onChange={(e) =>
+                                setSmtp({ ...smtp, username: e.target.value })
+                            }
+                            placeholder="user@example.com"
+                        />
+                        <Input
+                            label="Password"
+                            type="password"
+                            value={smtp.password}
+                            onChange={(e) =>
+                                setSmtp({ ...smtp, password: e.target.value })
+                            }
+                            placeholder="Password"
+                        />
+                    </div>
+
+                    <div className="grid grid-cols-2 gap-4">
+                        <Input
+                            label="From Address"
+                            type="email"
+                            value={smtp.from_address}
+                            onChange={(e) =>
+                                setSmtp({
+                                    ...smtp,
+                                    from_address: e.target.value,
+                                })
+                            }
+                            placeholder="noreply@example.com"
+                        />
+                        <Input
+                            label="From Name"
+                            value={smtp.from_name}
+                            onChange={(e) =>
+                                setSmtp({
+                                    ...smtp,
+                                    from_name: e.target.value,
+                                })
+                            }
+                            placeholder="Smartbotic"
+                        />
+                    </div>
+
+                    <div className="flex justify-end gap-3 pt-2">
+                        <Button
+                            variant="secondary"
+                            type="button"
+                            onClick={() => smtpTestMutation.mutate()}
+                            loading={smtpTestMutation.isPending}
+                        >
+                            <Send className="h-4 w-4" />
+                            Test SMTP
+                        </Button>
+                        <Button
+                            type="submit"
+                            loading={smtpMutation.isPending}
+                        >
+                            Save SMTP
+                        </Button>
+                    </div>
+                </form>
+            </Card>
+
+            {/* CallerAI Settings */}
+            <Card title="CallerAI Configuration">
+                <form onSubmit={handleCalleraiSave} className="space-y-4">
+                    {calleraiSuccess && (
+                        <div className="rounded-lg bg-green-50 border border-green-200 px-4 py-3 text-sm text-green-700">
+                            {calleraiSuccess}
+                        </div>
+                    )}
+                    {calleraiError && (
+                        <div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
+                            {calleraiError}
+                        </div>
+                    )}
+
+                    <Input
+                        label="API URL"
+                        value={callerai.api_url}
+                        onChange={(e) =>
+                            setCallerai({
+                                ...callerai,
+                                api_url: e.target.value,
+                            })
+                        }
+                        placeholder="https://api.callerai.com"
+                    />
+
+                    <Input
+                        label="API Key"
+                        type="password"
+                        value={callerai.api_key}
+                        onChange={(e) =>
+                            setCallerai({
+                                ...callerai,
+                                api_key: e.target.value,
+                            })
+                        }
+                        placeholder="Your API key"
+                    />
+
+                    <div className="flex justify-end gap-3 pt-2">
+                        <Button
+                            variant="secondary"
+                            type="button"
+                            onClick={() => calleraiTestMutation.mutate()}
+                            loading={calleraiTestMutation.isPending}
+                        >
+                            <Wifi className="h-4 w-4" />
+                            Test Connection
+                        </Button>
+                        <Button
+                            type="submit"
+                            loading={calleraiMutation.isPending}
+                        >
+                            Save CallerAI
+                        </Button>
+                    </div>
+                </form>
+            </Card>
+        </div>
+    );
+}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini