Jelajahi Sumber

feat: add archive service, batch documents, and LLM tool improvements

- Add ArchiveService for workspace archiving with cascade delete
- Add create_documents batch tool for inserting multiple documents
- Fix LLM tools to include workspace_id in all responses
- Add workspace_id argument support to tools for multi-workspace users
- Fix page component type: auto-correct "view" to "document-list"
- Handle flat and nested config formats in create/update page
- Add field display options to view editor (hidden, maxLength, detail settings)
- Add Archives page in WebUI with restore/export functionality
- Add soft delete support for views, pages, documents
- Add WebSocket events for archive operations
fszontagh 7 bulan lalu
induk
melakukan
20d9ec2216
34 mengubah file dengan 4625 tambahan dan 159 penghapusan
  1. 3 0
      llm/src/grpc/proto_convert.cpp
  2. 2 0
      webserver/CMakeLists.txt
  3. 282 0
      webserver/include/smartbotic/webserver/archive_service.hpp
  4. 14 1
      webserver/include/smartbotic/webserver/document_service.hpp
  5. 120 0
      webserver/include/smartbotic/webserver/event_manager.hpp
  6. 27 0
      webserver/include/smartbotic/webserver/http_server.hpp
  7. 29 4
      webserver/include/smartbotic/webserver/page_service.hpp
  8. 6 0
      webserver/include/smartbotic/webserver/permissions.hpp
  9. 46 1
      webserver/include/smartbotic/webserver/tool_service.hpp
  10. 49 4
      webserver/include/smartbotic/webserver/view_service.hpp
  11. 50 0
      webserver/include/smartbotic/webserver/workspace_service.hpp
  12. 21 0
      webserver/include/smartbotic/webserver/ws_handler.hpp
  13. 945 0
      webserver/src/archive_service.cpp
  14. 111 10
      webserver/src/document_service.cpp
  15. 196 0
      webserver/src/event_manager.cpp
  16. 567 2
      webserver/src/http_server.cpp
  17. 85 16
      webserver/src/page_service.cpp
  18. 6 0
      webserver/src/permissions.cpp
  19. 543 56
      webserver/src/tool_service.cpp
  20. 227 16
      webserver/src/view_service.cpp
  21. 177 3
      webserver/src/workspace_service.cpp
  22. 35 0
      webserver/src/ws_handler.cpp
  23. 2 0
      webui/src/App.tsx
  24. 19 19
      webui/src/components/Chat/ChatMessage.tsx
  25. 45 13
      webui/src/components/PageRenderer/DocumentListRenderer.tsx
  26. 1 0
      webui/src/components/PageRenderer/index.tsx
  27. 15 0
      webui/src/components/layout/Sidebar.tsx
  28. 96 10
      webui/src/contexts/ChatContext.tsx
  29. 34 0
      webui/src/contexts/WebSocketContext.tsx
  30. 79 0
      webui/src/contexts/WorkspaceContext.tsx
  31. 4 4
      webui/src/main.tsx
  32. 597 0
      webui/src/pages/Archives.tsx
  33. 191 0
      webui/src/pages/Views.tsx
  34. 1 0
      webui/src/types/index.ts

+ 3 - 0
llm/src/grpc/proto_convert.cpp

@@ -160,6 +160,9 @@ auto ProtoToContentPart(const ::smartbotic::llm::ContentPart& proto) -> session:
         part.tool_id = proto.tool_result().tool_use_id();
         part.tool_id = proto.tool_result().tool_use_id();
         part.text = proto.tool_result().content();
         part.text = proto.tool_result().content();
         part.is_error = proto.tool_result().is_error();
         part.is_error = proto.tool_result().is_error();
+    } else if (proto.has_thinking()) {
+        part.type = session::ContentPartType::kThinking;
+        part.text = proto.thinking().content();
     }
     }
 
 
     return part;
     return part;

+ 2 - 0
webserver/CMakeLists.txt

@@ -31,6 +31,8 @@ add_library(smartbotic_webserver STATIC
     src/permissions.cpp
     src/permissions.cpp
     src/authorization_service.cpp
     src/authorization_service.cpp
     src/tool_service.cpp
     src/tool_service.cpp
+    src/archive_service.cpp
+    src/event_manager.cpp
 )
 )
 
 
 target_include_directories(smartbotic_webserver
 target_include_directories(smartbotic_webserver

+ 282 - 0
webserver/include/smartbotic/webserver/archive_service.hpp

@@ -0,0 +1,282 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+#include "smartbotic/webserver/database_client.hpp"
+#include "smartbotic/webserver/event_manager.hpp"  // For EntityType
+
+namespace smartbotic::webserver {
+
+// Forward declarations
+class WorkspaceService;
+class PageService;
+class ViewService;
+class CollectionService;
+class DocumentService;
+
+/// Archive status
+enum class ArchiveStatus {
+    Active,     // Archive is active and can be restored
+    Expired,    // Archive has expired but not yet cleaned up
+    Restored,   // Archive was restored (kept for audit)
+    Exported    // Archive was exported
+};
+
+/// Convert ArchiveStatus to/from string
+auto ArchiveStatusToString(ArchiveStatus status) -> std::string;
+auto StringToArchiveStatus(const std::string& str) -> ArchiveStatus;
+
+// EntityType is defined in event_manager.hpp
+
+/// Convert EntityType to/from string (for archive use)
+auto StringToEntityType(const std::string& str) -> EntityType;
+
+/// Archive document structure
+struct ArchiveInfo {
+    std::string id;
+    EntityType entity_type;
+    std::string entity_id;          // Original entity ID
+    std::string workspace_id;       // Workspace that owned this entity
+    std::string parent_archive_id;  // Link to parent archive if cascaded
+    nlohmann::json data;            // Full entity data snapshot
+    int64_t children_count = 0;     // Number of child archives
+    std::string created_at;
+    std::string created_by;         // User ID who created the archive
+    std::string expires_at;         // When the archive will be permanently deleted
+    ArchiveStatus status = ArchiveStatus::Active;
+    std::string restored_at;        // When restored (if applicable)
+    std::string exported_at;        // When exported (if applicable)
+};
+
+/// Request to create an archive
+struct CreateArchiveRequest {
+    EntityType entity_type;
+    std::string entity_id;
+    std::string workspace_id;
+    std::string parent_archive_id;  // Optional: link to parent archive
+    nlohmann::json data;            // Full entity data snapshot
+    std::string actor_id;           // User performing the action
+    int retention_days = 30;        // Days to keep archive before expiration
+};
+
+/// Request to list archives
+struct ArchiveListRequest {
+    std::string workspace_id;       // Filter by workspace (optional)
+    EntityType entity_type;         // Filter by entity type (optional, use Workspace as default for all)
+    bool include_all_types = false; // If true, include all entity types
+    std::string parent_archive_id;  // Filter by parent archive (for children)
+    bool include_expired = false;   // Include expired archives
+    int limit = 100;
+    int offset = 0;
+};
+
+/// Result of an archive operation
+struct ArchiveResult {
+    bool success = false;
+    std::string error;
+    std::optional<ArchiveInfo> archive;
+};
+
+/// Result of listing archives
+struct ArchiveListResult {
+    bool success = false;
+    std::string error;
+    std::vector<ArchiveInfo> archives;
+    int64_t total_count = 0;
+};
+
+/// Result of restoring an archive
+struct RestoreResult {
+    bool success = false;
+    std::string error;
+    std::string restored_entity_id;  // ID of the restored entity
+    int restored_children_count = 0; // Number of child entities restored
+};
+
+/// Result of exporting an archive
+struct ExportResult {
+    bool success = false;
+    std::string error;
+    nlohmann::json export_data;      // Full export data including children
+};
+
+/// Result of cleanup operation
+struct CleanupResult {
+    bool success = false;
+    std::string error;
+    int cleaned_count = 0;           // Number of archives permanently deleted
+};
+
+/// Service for managing archives (soft-deleted entities)
+class ArchiveService {
+public:
+    /// System collection name for archives
+    static constexpr const char* kArchivesCollection = "_archives";
+
+    /// Default retention period in days
+    static constexpr int kDefaultRetentionDays = 30;
+
+    explicit ArchiveService(DatabaseClient& db_client);
+    ~ArchiveService();
+
+    // Disable copy
+    ArchiveService(const ArchiveService&) = delete;
+    auto operator=(const ArchiveService&) -> ArchiveService& = delete;
+
+    // Enable move
+    ArchiveService(ArchiveService&&) noexcept;
+    auto operator=(ArchiveService&&) noexcept -> ArchiveService&;
+
+    /// Initialize the _archives collection (create if doesn't exist)
+    [[nodiscard]] auto Initialize() -> bool;
+
+    /// Set service references for cascade operations
+    void SetServices(PageService* page_service,
+                     ViewService* view_service,
+                     CollectionService* collection_service,
+                     DocumentService* document_service);
+
+    // ========================================================================
+    // Core Archive Operations
+    // ========================================================================
+
+    /// Create an archive snapshot
+    /// @param request Archive creation request
+    /// @return Result with created archive or error
+    [[nodiscard]] auto CreateArchive(const CreateArchiveRequest& request) -> ArchiveResult;
+
+    /// Get an archive by ID
+    /// @param id Archive ID
+    /// @return Result with archive or error
+    [[nodiscard]] auto GetArchive(const std::string& id) -> ArchiveResult;
+
+    /// List archives with filters
+    /// @param request List request with filters
+    /// @return Result with list of archives
+    [[nodiscard]] auto ListArchives(const ArchiveListRequest& request) -> ArchiveListResult;
+
+    /// Restore an archive (recreate the entity from snapshot)
+    /// @param archive_id Archive ID to restore
+    /// @param actor_id User performing the restore
+    /// @param restore_children If true, also restore child archives
+    /// @return Result with restored entity ID
+    [[nodiscard]] auto RestoreArchive(const std::string& archive_id,
+                                       const std::string& actor_id,
+                                       bool restore_children = true) -> RestoreResult;
+
+    /// Export an archive to JSON (including all children)
+    /// @param archive_id Archive ID to export
+    /// @return Result with export data
+    [[nodiscard]] auto ExportArchive(const std::string& archive_id) -> ExportResult;
+
+    /// Permanently delete an archive
+    /// @param archive_id Archive ID to delete
+    /// @param delete_children If true, also delete child archives
+    /// @return Result indicating success or error
+    [[nodiscard]] auto DeleteArchive(const std::string& archive_id,
+                                      bool delete_children = true) -> ArchiveResult;
+
+    /// Cleanup expired archives (called periodically)
+    /// @return Result with number of archives cleaned up
+    [[nodiscard]] auto CleanupExpiredArchives() -> CleanupResult;
+
+    // ========================================================================
+    // Cascade Archive Operations
+    // ========================================================================
+
+    /// Archive a workspace and all its children
+    /// @param workspace_id Workspace ID to archive
+    /// @param workspace_data Workspace data snapshot
+    /// @param actor_id User performing the action
+    /// @param retention_days Days to keep archive
+    /// @return Result with created archive
+    [[nodiscard]] auto ArchiveWorkspace(const std::string& workspace_id,
+                                         const nlohmann::json& workspace_data,
+                                         const std::string& actor_id,
+                                         int retention_days = kDefaultRetentionDays) -> ArchiveResult;
+
+    /// Archive a page
+    /// @param page_id Page ID to archive
+    /// @param page_data Page data snapshot
+    /// @param workspace_id Workspace ID
+    /// @param parent_archive_id Parent archive ID (if cascaded from workspace)
+    /// @param actor_id User performing the action
+    /// @param retention_days Days to keep archive
+    /// @return Result with created archive
+    [[nodiscard]] auto ArchivePage(const std::string& page_id,
+                                    const nlohmann::json& page_data,
+                                    const std::string& workspace_id,
+                                    const std::string& parent_archive_id,
+                                    const std::string& actor_id,
+                                    int retention_days = kDefaultRetentionDays) -> ArchiveResult;
+
+    /// Archive a view
+    [[nodiscard]] auto ArchiveView(const std::string& view_id,
+                                    const nlohmann::json& view_data,
+                                    const std::string& workspace_id,
+                                    const std::string& parent_archive_id,
+                                    const std::string& actor_id,
+                                    int retention_days = kDefaultRetentionDays) -> ArchiveResult;
+
+    /// Archive a collection
+    [[nodiscard]] auto ArchiveCollection(const std::string& collection_name,
+                                          const nlohmann::json& collection_data,
+                                          const std::string& workspace_id,
+                                          const std::string& parent_archive_id,
+                                          const std::string& actor_id,
+                                          int retention_days = kDefaultRetentionDays) -> ArchiveResult;
+
+    /// Archive a document
+    [[nodiscard]] auto ArchiveDocument(const std::string& document_id,
+                                        const nlohmann::json& document_data,
+                                        const std::string& collection_name,
+                                        const std::string& workspace_id,
+                                        const std::string& parent_archive_id,
+                                        const std::string& actor_id,
+                                        int retention_days = kDefaultRetentionDays) -> ArchiveResult;
+
+    /// Archive a chat session
+    [[nodiscard]] auto ArchiveSession(const std::string& session_id,
+                                       const nlohmann::json& session_data,
+                                       const std::string& workspace_id,
+                                       const std::string& parent_archive_id,
+                                       const std::string& actor_id,
+                                       int retention_days = kDefaultRetentionDays) -> ArchiveResult;
+
+private:
+    /// Convert ArchiveInfo to JSON for storage
+    [[nodiscard]] static auto ArchiveToJson(const ArchiveInfo& archive) -> nlohmann::json;
+
+    /// Convert JSON from storage to ArchiveInfo
+    [[nodiscard]] static auto JsonToArchive(const nlohmann::json& json) -> ArchiveInfo;
+
+    /// Get current ISO 8601 timestamp
+    [[nodiscard]] static auto GetCurrentTimestamp() -> std::string;
+
+    /// Calculate expiration timestamp
+    [[nodiscard]] static auto CalculateExpiration(int retention_days) -> std::string;
+
+    /// Check if a timestamp has expired
+    [[nodiscard]] static auto IsExpired(const std::string& expires_at) -> bool;
+
+    /// Update children count for a parent archive
+    auto UpdateChildrenCount(const std::string& archive_id, int64_t delta) -> bool;
+
+    /// Get all child archives for a parent
+    [[nodiscard]] auto GetChildArchives(const std::string& parent_archive_id) -> ArchiveListResult;
+
+    DatabaseClient& db_client_;
+    bool initialized_ = false;
+
+    // Service references for cascade operations
+    PageService* page_service_ = nullptr;
+    ViewService* view_service_ = nullptr;
+    CollectionService* collection_service_ = nullptr;
+    DocumentService* document_service_ = nullptr;
+};
+
+}  // namespace smartbotic::webserver

+ 14 - 1
webserver/include/smartbotic/webserver/document_service.hpp

@@ -10,6 +10,9 @@
 
 
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
+// Forward declarations
+class EventManager;
+
 /// Document info returned to clients
 /// Document info returned to clients
 struct DocumentInfo {
 struct DocumentInfo {
     std::string id;
     std::string id;
@@ -18,7 +21,11 @@ struct DocumentInfo {
     nlohmann::json data;
     nlohmann::json data;
     std::string created_at;
     std::string created_at;
     std::string updated_at;
     std::string updated_at;
+    std::string deleted_at;  // Empty if not deleted (soft delete)
     int64_t version = 0;
     int64_t version = 0;
+
+    /// Check if document is deleted (soft delete)
+    [[nodiscard]] auto IsDeleted() const -> bool { return !deleted_at.empty(); }
 };
 };
 
 
 /// Request to create a document
 /// Request to create a document
@@ -51,6 +58,7 @@ struct DocumentQuery {
     int32_t limit = 100;
     int32_t limit = 100;
     int32_t offset = 0;
     int32_t offset = 0;
     std::string owner_filter;  // If set, filter to documents created by this user (_created_by)
     std::string owner_filter;  // If set, filter to documents created by this user (_created_by)
+    bool include_deleted = false;  // Include soft-deleted documents
 };
 };
 
 
 /// Result of a document operation
 /// Result of a document operation
@@ -74,6 +82,9 @@ public:
     explicit DocumentService(DatabaseClient& db_client);
     explicit DocumentService(DatabaseClient& db_client);
     ~DocumentService();
     ~DocumentService();
 
 
+    /// Set the event manager for real-time notifications
+    void SetEventManager(EventManager* event_manager);
+
     // Disable copy
     // Disable copy
     DocumentService(const DocumentService&) = delete;
     DocumentService(const DocumentService&) = delete;
     auto operator=(const DocumentService&) -> DocumentService& = delete;
     auto operator=(const DocumentService&) -> DocumentService& = delete;
@@ -91,9 +102,10 @@ public:
     /// @param workspace_id Workspace ID
     /// @param workspace_id Workspace ID
     /// @param collection Collection name (display name)
     /// @param collection Collection name (display name)
     /// @param id Document ID
     /// @param id Document ID
+    /// @param include_deleted Include soft-deleted documents
     /// @return Result with document info
     /// @return Result with document info
     [[nodiscard]] auto GetDocument(const std::string& workspace_id, const std::string& collection,
     [[nodiscard]] auto GetDocument(const std::string& workspace_id, const std::string& collection,
-                                   const std::string& id) -> DocumentResult;
+                                   const std::string& id, bool include_deleted = false) -> DocumentResult;
 
 
     /// List/query documents in a collection
     /// List/query documents in a collection
     /// @param query Query parameters
     /// @param query Query parameters
@@ -136,6 +148,7 @@ private:
     [[nodiscard]] static auto JsonToValue(const nlohmann::json& json) -> ::smartbotic::database::Value;
     [[nodiscard]] static auto JsonToValue(const nlohmann::json& json) -> ::smartbotic::database::Value;
 
 
     DatabaseClient& db_client_;
     DatabaseClient& db_client_;
+    EventManager* event_manager_ = nullptr;
 };
 };
 
 
 }  // namespace smartbotic::webserver
 }  // namespace smartbotic::webserver

+ 120 - 0
webserver/include/smartbotic/webserver/event_manager.hpp

@@ -0,0 +1,120 @@
+#pragma once
+
+#include <functional>
+#include <mutex>
+#include <string>
+#include <vector>
+
+#include <nlohmann/json.hpp>
+
+namespace smartbotic::webserver {
+
+/// Entity types for events
+enum class EntityType {
+    Workspace,
+    Page,
+    View,
+    Collection,
+    Document,
+    Session,
+    User,
+    Group,
+    Membership
+};
+
+/// Action types for events
+enum class EventAction {
+    Create,
+    Update,
+    Delete
+};
+
+/// Convert EntityType to string
+[[nodiscard]] auto EntityTypeToString(EntityType type) -> std::string;
+
+/// Convert EventAction to string
+[[nodiscard]] auto EventActionToString(EventAction action) -> std::string;
+
+/// Unified event structure for all entity changes
+struct EntityEvent {
+    EntityType entity_type;
+    EventAction action;
+    std::string entity_id;
+    std::string workspace_id;      // Empty for system-level entities (users, groups)
+    std::string collection_name;   // Only for documents
+    nlohmann::json data;           // Entity data (for create/update)
+    std::string actor_id;          // User who triggered the change
+};
+
+/// Function type for event listeners
+using EventListener = std::function<void(const EntityEvent& event)>;
+
+/// Centralized event manager for all entity changes
+/// All services notify this manager when entities are created, updated, or deleted.
+/// The HttpServer subscribes to receive events and broadcasts them to WebSocket clients.
+class EventManager {
+public:
+    EventManager() = default;
+    ~EventManager() = default;
+
+    // Disable copy and move (has mutex)
+    EventManager(const EventManager&) = delete;
+    auto operator=(const EventManager&) -> EventManager& = delete;
+    EventManager(EventManager&&) = delete;
+    auto operator=(EventManager&&) -> EventManager& = delete;
+
+    /// Add an event listener
+    /// @param listener Function to call when events occur
+    /// @return Listener ID for removal
+    auto AddListener(EventListener listener) -> size_t;
+
+    /// Remove an event listener
+    /// @param listener_id ID returned from AddListener
+    void RemoveListener(size_t listener_id);
+
+    /// Emit an event to all listeners
+    /// @param event The event to emit
+    void Emit(const EntityEvent& event);
+
+    /// Helper methods for common event types
+    void EmitWorkspaceEvent(EventAction action, const std::string& workspace_id,
+                            const nlohmann::json& data, const std::string& actor_id = "");
+
+    void EmitPageEvent(EventAction action, const std::string& page_id,
+                       const std::string& workspace_id, const nlohmann::json& data,
+                       const std::string& actor_id = "");
+
+    void EmitViewEvent(EventAction action, const std::string& view_id,
+                       const std::string& workspace_id, const nlohmann::json& data,
+                       const std::string& actor_id = "");
+
+    void EmitCollectionEvent(EventAction action, const std::string& collection_name,
+                             const std::string& workspace_id, const nlohmann::json& data,
+                             const std::string& actor_id = "");
+
+    void EmitDocumentEvent(EventAction action, const std::string& document_id,
+                           const std::string& workspace_id, const std::string& collection_name,
+                           const nlohmann::json& data, const std::string& actor_id = "");
+
+    void EmitSessionEvent(EventAction action, const std::string& session_id,
+                          const std::string& workspace_id, const nlohmann::json& data,
+                          const std::string& actor_id = "");
+
+    void EmitUserEvent(EventAction action, const std::string& user_id,
+                       const nlohmann::json& data, const std::string& actor_id = "");
+
+    void EmitGroupEvent(EventAction action, const std::string& group_id,
+                        const std::string& workspace_id, const nlohmann::json& data,
+                        const std::string& actor_id = "");
+
+    void EmitMembershipEvent(EventAction action, const std::string& membership_id,
+                             const std::string& workspace_id, const nlohmann::json& data,
+                             const std::string& actor_id = "");
+
+private:
+    std::mutex mutex_;
+    std::vector<std::pair<size_t, EventListener>> listeners_;
+    size_t next_listener_id_ = 0;
+};
+
+}  // namespace smartbotic::webserver

+ 27 - 0
webserver/include/smartbotic/webserver/http_server.hpp

@@ -29,6 +29,8 @@
 #include "smartbotic/webserver/ws_handler.hpp"
 #include "smartbotic/webserver/ws_handler.hpp"
 #include "smartbotic/webserver/llm_client.hpp"
 #include "smartbotic/webserver/llm_client.hpp"
 #include "smartbotic/webserver/tool_service.hpp"
 #include "smartbotic/webserver/tool_service.hpp"
+#include "smartbotic/webserver/archive_service.hpp"
+#include "smartbotic/webserver/event_manager.hpp"
 
 
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
@@ -82,6 +84,9 @@ public:
     /// Send a message to all connections subscribed to a collection
     /// Send a message to all connections subscribed to a collection
     void SendToSubscribers(const std::string& subscription_key, const std::string& message);
     void SendToSubscribers(const std::string& subscription_key, const std::string& message);
 
 
+    /// Send a message to all authenticated connections
+    void SendToAuthenticated(const std::string& message);
+
     /// Get the number of connected clients
     /// Get the number of connected clients
     [[nodiscard]] auto ConnectionCount() const -> size_t;
     [[nodiscard]] auto ConnectionCount() const -> size_t;
 
 
@@ -198,6 +203,12 @@ public:
     /// Get the WebSocket handler
     /// Get the WebSocket handler
     [[nodiscard]] auto GetWsHandler() -> WsHandler& { return *wsHandler_; }
     [[nodiscard]] auto GetWsHandler() -> WsHandler& { return *wsHandler_; }
 
 
+    /// Get the archive service
+    [[nodiscard]] auto GetArchiveService() -> ArchiveService& { return *archiveService_; }
+
+    /// Get the event manager
+    [[nodiscard]] auto GetEventManager() -> EventManager& { return *eventManager_; }
+
     /// Broadcast a document event to subscribed WebSocket clients
     /// Broadcast a document event to subscribed WebSocket clients
     void BroadcastDocumentEvent(const std::string& workspace_id,
     void BroadcastDocumentEvent(const std::string& workspace_id,
                                  const std::string& collection,
                                  const std::string& collection,
@@ -205,6 +216,11 @@ public:
                                  const std::string& document_id,
                                  const std::string& document_id,
                                  const nlohmann::json& data);
                                  const nlohmann::json& data);
 
 
+    /// Broadcast a workspace event to all authenticated WebSocket clients
+    void BroadcastWorkspaceEvent(const std::string& workspace_id,
+                                  WorkspaceAction action,
+                                  const nlohmann::json& data);
+
     /// Broadcast a chat chunk to subscribed WebSocket clients
     /// Broadcast a chat chunk to subscribed WebSocket clients
     void BroadcastChatChunk(const std::string& workspace_id,
     void BroadcastChatChunk(const std::string& workspace_id,
                             const std::string& session_id,
                             const std::string& session_id,
@@ -229,6 +245,7 @@ private:
     void SetupViewRoutes();
     void SetupViewRoutes();
     void SetupPageRoutes();
     void SetupPageRoutes();
     void SetupLlmRoutes();
     void SetupLlmRoutes();
+    void SetupArchiveRoutes();
     void RunHttpServer();
     void RunHttpServer();
     [[nodiscard]] auto ConnectToDatabase() -> bool;
     [[nodiscard]] auto ConnectToDatabase() -> bool;
     [[nodiscard]] auto InitializeServices() -> bool;
     [[nodiscard]] auto InitializeServices() -> bool;
@@ -347,6 +364,14 @@ private:
     void HandleLlmSyncChunks(const httplib::Request& req, httplib::Response& res);
     void HandleLlmSyncChunks(const httplib::Request& req, httplib::Response& res);
     void HandleLlmGetStreamingState(const httplib::Request& req, httplib::Response& res);
     void HandleLlmGetStreamingState(const httplib::Request& req, httplib::Response& res);
 
 
+    // Archive route handlers
+    void HandleListArchives(const httplib::Request& req, httplib::Response& res);
+    void HandleGetArchive(const httplib::Request& req, httplib::Response& res);
+    void HandleRestoreArchive(const httplib::Request& req, httplib::Response& res);
+    void HandleExportArchive(const httplib::Request& req, httplib::Response& res);
+    void HandleDeleteArchive(const httplib::Request& req, httplib::Response& res);
+    void HandleCleanupArchives(const httplib::Request& req, httplib::Response& res);
+
     // Authentication middleware helper
     // Authentication middleware helper
     [[nodiscard]] auto AuthenticateRequest(const httplib::Request& req) -> std::optional<AuthUser>;
     [[nodiscard]] auto AuthenticateRequest(const httplib::Request& req) -> std::optional<AuthUser>;
 
 
@@ -379,6 +404,8 @@ private:
     std::unique_ptr<WsHandler> wsHandler_;
     std::unique_ptr<WsHandler> wsHandler_;
     std::unique_ptr<LlmClient> llmClient_;
     std::unique_ptr<LlmClient> llmClient_;
     std::unique_ptr<ToolService> toolService_;
     std::unique_ptr<ToolService> toolService_;
+    std::unique_ptr<ArchiveService> archiveService_;
+    std::unique_ptr<EventManager> eventManager_;
 
 
     std::thread httpThread_;
     std::thread httpThread_;
 
 

+ 29 - 4
webserver/include/smartbotic/webserver/page_service.hpp

@@ -10,6 +10,9 @@
 
 
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
+// Forward declarations
+class EventManager;
+
 /// Grid position for a layout component
 /// Grid position for a layout component
 struct GridPosition {
 struct GridPosition {
     int x = 0;       // Column start (0-11)
     int x = 0;       // Column start (0-11)
@@ -50,8 +53,12 @@ struct PageInfo {
     PageSettings settings;
     PageSettings settings;
     std::string created_at;
     std::string created_at;
     std::string updated_at;
     std::string updated_at;
+    std::string deleted_at;                       // Empty if not deleted (soft delete)
     std::string created_by;                       // Owner - user ID
     std::string created_by;                       // Owner - user ID
     std::vector<std::string> shared_with_groups;  // Groups that can view this page
     std::vector<std::string> shared_with_groups;  // Groups that can view this page
+
+    /// Check if page is deleted (soft delete)
+    [[nodiscard]] auto IsDeleted() const -> bool { return !deleted_at.empty(); }
 };
 };
 
 
 /// Request to create a page
 /// Request to create a page
@@ -95,6 +102,9 @@ public:
     explicit PageService(DatabaseClient& db_client);
     explicit PageService(DatabaseClient& db_client);
     ~PageService();
     ~PageService();
 
 
+    /// Set the event manager for real-time notifications
+    void SetEventManager(EventManager* event_manager);
+
     // Disable copy
     // Disable copy
     PageService(const PageService&) = delete;
     PageService(const PageService&) = delete;
     auto operator=(const PageService&) -> PageService& = delete;
     auto operator=(const PageService&) -> PageService& = delete;
@@ -110,17 +120,31 @@ public:
     [[nodiscard]] auto CreatePage(const CreatePageRequest& request) -> PageResult;
     [[nodiscard]] auto CreatePage(const CreatePageRequest& request) -> PageResult;
 
 
     /// Get a page by ID
     /// Get a page by ID
-    [[nodiscard]] auto GetPage(const std::string& workspace_id, const std::string& id) -> PageResult;
+    /// @param workspace_id Workspace ID
+    /// @param id Page ID
+    /// @param include_deleted Include soft-deleted pages
+    [[nodiscard]] auto GetPage(const std::string& workspace_id, const std::string& id,
+                               bool include_deleted = false) -> PageResult;
 
 
     /// Get a page by slug
     /// Get a page by slug
+    /// @param workspace_id Workspace ID
+    /// @param slug Page slug
+    /// @param include_deleted Include soft-deleted pages
     [[nodiscard]] auto GetPageBySlug(const std::string& workspace_id,
     [[nodiscard]] auto GetPageBySlug(const std::string& workspace_id,
-                                      const std::string& slug) -> PageResult;
+                                      const std::string& slug,
+                                      bool include_deleted = false) -> PageResult;
 
 
     /// List all pages in a workspace
     /// List all pages in a workspace
-    [[nodiscard]] auto ListPages(const std::string& workspace_id) -> PageListResult;
+    /// @param workspace_id Workspace ID
+    /// @param include_deleted Include soft-deleted pages
+    [[nodiscard]] auto ListPages(const std::string& workspace_id,
+                                 bool include_deleted = false) -> PageListResult;
 
 
     /// List pages for sidebar (show_in_sidebar = true)
     /// List pages for sidebar (show_in_sidebar = true)
-    [[nodiscard]] auto ListSidebarPages(const std::string& workspace_id) -> PageListResult;
+    /// @param workspace_id Workspace ID
+    /// @param include_deleted Include soft-deleted pages
+    [[nodiscard]] auto ListSidebarPages(const std::string& workspace_id,
+                                        bool include_deleted = false) -> PageListResult;
 
 
     /// Update a page
     /// Update a page
     [[nodiscard]] auto UpdatePage(const UpdatePageRequest& request) -> PageResult;
     [[nodiscard]] auto UpdatePage(const UpdatePageRequest& request) -> PageResult;
@@ -159,6 +183,7 @@ private:
     [[nodiscard]] static auto GenerateSlug(const std::string& name) -> std::string;
     [[nodiscard]] static auto GenerateSlug(const std::string& name) -> std::string;
 
 
     DatabaseClient& db_client_;
     DatabaseClient& db_client_;
+    EventManager* event_manager_ = nullptr;
 };
 };
 
 
 }  // namespace smartbotic::webserver
 }  // namespace smartbotic::webserver

+ 6 - 0
webserver/include/smartbotic/webserver/permissions.hpp

@@ -77,6 +77,12 @@ constexpr std::string_view kLlmProvidersDelete = "system:llm_providers:delete";
 // LLM Chat access (system-level)
 // LLM Chat access (system-level)
 constexpr std::string_view kLlmChat = "system:llm:chat";
 constexpr std::string_view kLlmChat = "system:llm:chat";
 
 
+// Archive management (system-level)
+constexpr std::string_view kArchivesRead = "system:archives:read";
+constexpr std::string_view kArchivesRestore = "system:archives:restore";
+constexpr std::string_view kArchivesDelete = "system:archives:delete";
+constexpr std::string_view kArchivesExport = "system:archives:export";
+
 // ============================================================================
 // ============================================================================
 // WORKSPACE SCOPE - Per-workspace operations
 // WORKSPACE SCOPE - Per-workspace operations
 // Format: workspace:{workspace_id}:{action}
 // Format: workspace:{workspace_id}:{action}

+ 46 - 1
webserver/include/smartbotic/webserver/tool_service.hpp

@@ -20,6 +20,7 @@ class ViewService;
 class PageService;
 class PageService;
 class DocumentService;
 class DocumentService;
 class CollectionService;
 class CollectionService;
+class ArchiveService;
 
 
 /// Permission requirements for a tool
 /// Permission requirements for a tool
 struct ToolPermissions {
 struct ToolPermissions {
@@ -70,6 +71,9 @@ using CrmToolExecutor = std::function<ToolExecutionResult(
     const nlohmann::json& arguments,
     const nlohmann::json& arguments,
     const ToolExecutionContext& context)>;
     const ToolExecutionContext& context)>;
 
 
+// Forward declaration
+class MembershipService;
+
 /// Service for managing CRM tools available to the LLM
 /// Service for managing CRM tools available to the LLM
 class ToolService {
 class ToolService {
 public:
 public:
@@ -79,7 +83,9 @@ public:
                 ViewService& view_service,
                 ViewService& view_service,
                 PageService& page_service,
                 PageService& page_service,
                 DocumentService& document_service,
                 DocumentService& document_service,
-                CollectionService& collection_service);
+                CollectionService& collection_service,
+                MembershipService& membership_service,
+                ArchiveService* archive_service = nullptr);
     ~ToolService();
     ~ToolService();
 
 
     // Disable copy
     // Disable copy
@@ -159,6 +165,15 @@ public:
     [[nodiscard]] static auto ToLlmToolsJson(const std::vector<CrmToolDefinition>& tools)
     [[nodiscard]] static auto ToLlmToolsJson(const std::vector<CrmToolDefinition>& tools)
         -> nlohmann::json;
         -> nlohmann::json;
 
 
+    /// Convert tool definitions to JSON format for LLM API (user-aware version)
+    /// Dynamically adjusts schema based on user permissions:
+    /// - Hides workspace_id parameter if user only has access to one workspace
+    /// @param tools List of tool definitions
+    /// @param user The authenticated user
+    /// @return JSON array of tool definitions with user-specific adjustments
+    [[nodiscard]] static auto ToLlmToolsJson(const std::vector<CrmToolDefinition>& tools,
+                                              const AuthUser& user) -> nlohmann::json;
+
 private:
 private:
     /// Register all built-in navigation tools
     /// Register all built-in navigation tools
     void RegisterNavigationTools();
     void RegisterNavigationTools();
@@ -175,11 +190,21 @@ private:
     /// Register all built-in document tools
     /// Register all built-in document tools
     void RegisterDocumentTools();
     void RegisterDocumentTools();
 
 
+    /// Register schema-aware tools (get_view_schema, update_document_with_validation)
+    void RegisterSchemaTools();
+
+    /// Register archive tools (delete_workspace, list_archives, restore_archive, export_archive)
+    void RegisterArchiveTools();
+
     /// Check if user has required permissions for a tool
     /// Check if user has required permissions for a tool
     [[nodiscard]] auto HasToolPermissions(const AuthUser& user,
     [[nodiscard]] auto HasToolPermissions(const AuthUser& user,
                                            const CrmToolDefinition& tool,
                                            const CrmToolDefinition& tool,
                                            const std::string& workspace_id) -> bool;
                                            const std::string& workspace_id) -> bool;
 
 
+    /// Check if user has access to a workspace (uses real-time membership check as fallback)
+    [[nodiscard]] auto HasWorkspaceAccess(const AuthUser& user,
+                                           const std::string& workspace_id) -> bool;
+
     // Navigation tool handlers (return navigation data for client)
     // Navigation tool handlers (return navigation data for client)
     [[nodiscard]] auto HandleNavigateToPage(const nlohmann::json& args,
     [[nodiscard]] auto HandleNavigateToPage(const nlohmann::json& args,
                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
@@ -223,6 +248,8 @@ private:
     // Document tool handlers
     // Document tool handlers
     [[nodiscard]] auto HandleCreateDocument(const nlohmann::json& args,
     [[nodiscard]] auto HandleCreateDocument(const nlohmann::json& args,
                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleCreateDocuments(const nlohmann::json& args,
+                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
     [[nodiscard]] auto HandleUpdateDocument(const nlohmann::json& args,
     [[nodiscard]] auto HandleUpdateDocument(const nlohmann::json& args,
                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
     [[nodiscard]] auto HandleGetDocument(const nlohmann::json& args,
     [[nodiscard]] auto HandleGetDocument(const nlohmann::json& args,
@@ -234,12 +261,30 @@ private:
     [[nodiscard]] auto HandleQueryRecords(const nlohmann::json& args,
     [[nodiscard]] auto HandleQueryRecords(const nlohmann::json& args,
                                            const ToolExecutionContext& ctx) -> ToolExecutionResult;
                                            const ToolExecutionContext& ctx) -> ToolExecutionResult;
 
 
+    // Schema-aware tool handlers
+    [[nodiscard]] auto HandleGetViewSchema(const nlohmann::json& args,
+                                            const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleUpdateDocumentWithValidation(const nlohmann::json& args,
+                                                           const ToolExecutionContext& ctx) -> ToolExecutionResult;
+
+    // Archive tool handlers
+    [[nodiscard]] auto HandleDeleteWorkspace(const nlohmann::json& args,
+                                              const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleListArchives(const nlohmann::json& args,
+                                           const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleRestoreArchive(const nlohmann::json& args,
+                                             const ToolExecutionContext& ctx) -> ToolExecutionResult;
+    [[nodiscard]] auto HandleExportArchive(const nlohmann::json& args,
+                                            const ToolExecutionContext& ctx) -> ToolExecutionResult;
+
     AuthorizationService& authorization_service_;
     AuthorizationService& authorization_service_;
     WorkspaceService& workspace_service_;
     WorkspaceService& workspace_service_;
     ViewService& view_service_;
     ViewService& view_service_;
     PageService& page_service_;
     PageService& page_service_;
     DocumentService& document_service_;
     DocumentService& document_service_;
     CollectionService& collection_service_;
     CollectionService& collection_service_;
+    MembershipService& membership_service_;
+    ArchiveService* archive_service_;
 
 
     /// Registered tools: name -> definition
     /// Registered tools: name -> definition
     std::unordered_map<std::string, CrmToolDefinition> tools_;
     std::unordered_map<std::string, CrmToolDefinition> tools_;

+ 49 - 4
webserver/include/smartbotic/webserver/view_service.hpp

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include <map>
 #include <optional>
 #include <optional>
 #include <string>
 #include <string>
 #include <vector>
 #include <vector>
@@ -10,6 +11,9 @@
 
 
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
+// Forward declarations
+class EventManager;
+
 /// Field type for schema definitions
 /// Field type for schema definitions
 enum class FieldType {
 enum class FieldType {
     Text,
     Text,
@@ -80,6 +84,10 @@ struct ViewInfo {
     ViewSettings settings;
     ViewSettings settings;
     std::string created_at;
     std::string created_at;
     std::string updated_at;
     std::string updated_at;
+    std::string deleted_at;  // Empty if not deleted (soft delete)
+
+    /// Check if view is deleted (soft delete)
+    [[nodiscard]] auto IsDeleted() const -> bool { return !deleted_at.empty(); }
 };
 };
 
 
 /// Request to create a view
 /// Request to create a view
@@ -115,12 +123,23 @@ struct ViewListResult {
     std::vector<ViewInfo> views;
     std::vector<ViewInfo> views;
 };
 };
 
 
+/// Result of validating data against a view schema
+struct SchemaValidationResult {
+    bool valid = true;
+    std::vector<std::string> errors;
+    /// Maps field name to valid options (for select fields with invalid values)
+    std::map<std::string, std::vector<std::string>> field_suggestions;
+};
+
 /// Service for managing views/schema definitions
 /// Service for managing views/schema definitions
 class ViewService {
 class ViewService {
 public:
 public:
     explicit ViewService(DatabaseClient& db_client);
     explicit ViewService(DatabaseClient& db_client);
     ~ViewService();
     ~ViewService();
 
 
+    /// Set the event manager for real-time notifications
+    void SetEventManager(EventManager* event_manager);
+
     // Disable copy
     // Disable copy
     ViewService(const ViewService&) = delete;
     ViewService(const ViewService&) = delete;
     auto operator=(const ViewService&) -> ViewService& = delete;
     auto operator=(const ViewService&) -> ViewService& = delete;
@@ -136,18 +155,33 @@ public:
     [[nodiscard]] auto CreateView(const CreateViewRequest& request) -> ViewResult;
     [[nodiscard]] auto CreateView(const CreateViewRequest& request) -> ViewResult;
 
 
     /// Get a view by ID
     /// Get a view by ID
-    [[nodiscard]] auto GetView(const std::string& workspace_id, const std::string& id) -> ViewResult;
+    /// @param workspace_id Workspace ID
+    /// @param id View ID
+    /// @param include_deleted Include soft-deleted views
+    [[nodiscard]] auto GetView(const std::string& workspace_id, const std::string& id,
+                               bool include_deleted = false) -> ViewResult;
 
 
     /// Get a view by name
     /// Get a view by name
+    /// @param workspace_id Workspace ID
+    /// @param name View name
+    /// @param include_deleted Include soft-deleted views
     [[nodiscard]] auto GetViewByName(const std::string& workspace_id,
     [[nodiscard]] auto GetViewByName(const std::string& workspace_id,
-                                      const std::string& name) -> ViewResult;
+                                      const std::string& name,
+                                      bool include_deleted = false) -> ViewResult;
 
 
     /// List all views in a workspace
     /// List all views in a workspace
-    [[nodiscard]] auto ListViews(const std::string& workspace_id) -> ViewListResult;
+    /// @param workspace_id Workspace ID
+    /// @param include_deleted Include soft-deleted views
+    [[nodiscard]] auto ListViews(const std::string& workspace_id,
+                                 bool include_deleted = false) -> ViewListResult;
 
 
     /// List views for a specific collection
     /// List views for a specific collection
+    /// @param workspace_id Workspace ID
+    /// @param collection_name Collection name
+    /// @param include_deleted Include soft-deleted views
     [[nodiscard]] auto ListViewsForCollection(const std::string& workspace_id,
     [[nodiscard]] auto ListViewsForCollection(const std::string& workspace_id,
-                                               const std::string& collection_name) -> ViewListResult;
+                                               const std::string& collection_name,
+                                               bool include_deleted = false) -> ViewListResult;
 
 
     /// Update a view
     /// Update a view
     [[nodiscard]] auto UpdateView(const UpdateViewRequest& request) -> ViewResult;
     [[nodiscard]] auto UpdateView(const UpdateViewRequest& request) -> ViewResult;
@@ -155,6 +189,16 @@ public:
     /// Delete a view
     /// Delete a view
     [[nodiscard]] auto DeleteView(const std::string& workspace_id, const std::string& id) -> ViewResult;
     [[nodiscard]] auto DeleteView(const std::string& workspace_id, const std::string& id) -> ViewResult;
 
 
+    /// Validate data against a view's schema
+    /// Checks select field values, required fields, etc.
+    [[nodiscard]] static auto ValidateDataAgainstSchema(
+        const nlohmann::json& data,
+        const ViewSchema& schema) -> SchemaValidationResult;
+
+    /// Get the default view for a collection
+    [[nodiscard]] auto GetDefaultViewForCollection(const std::string& workspace_id,
+                                                    const std::string& collection_name) -> ViewResult;
+
 private:
 private:
     /// System collection name for views
     /// System collection name for views
     static constexpr const char* kViewsCollection = "_views";
     static constexpr const char* kViewsCollection = "_views";
@@ -178,6 +222,7 @@ private:
     [[nodiscard]] static auto JsonToSettings(const nlohmann::json& json) -> ViewSettings;
     [[nodiscard]] static auto JsonToSettings(const nlohmann::json& json) -> ViewSettings;
 
 
     DatabaseClient& db_client_;
     DatabaseClient& db_client_;
+    EventManager* event_manager_ = nullptr;
 };
 };
 
 
 }  // namespace smartbotic::webserver
 }  // namespace smartbotic::webserver

+ 50 - 0
webserver/include/smartbotic/webserver/workspace_service.hpp

@@ -10,6 +10,14 @@
 
 
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
+// Forward declarations
+class ArchiveService;
+class PageService;
+class ViewService;
+class CollectionService;
+class DocumentService;
+class EventManager;
+
 /// Workspace data structure
 /// Workspace data structure
 struct Workspace {
 struct Workspace {
     std::string id;
     std::string id;
@@ -39,6 +47,22 @@ struct UpdateWorkspaceRequest {
     std::string actor_id;  // User ID performing the action
     std::string actor_id;  // User ID performing the action
 };
 };
 
 
+/// Options for deleting a workspace with cascade and archive
+struct DeleteWorkspaceOptions {
+    bool create_archive = true;     // Create archive before delete
+    int retention_days = 30;        // Days to keep archive
+    std::string actor_id;           // User performing the action
+    bool cascade_delete = true;     // Cascade delete to child entities (pages, views, etc.)
+};
+
+/// Result of deleting a workspace with archive
+struct DeleteWorkspaceResult {
+    bool success = false;
+    std::string error;
+    std::string archive_id;         // ID of the created archive (if archiving was requested)
+    int cascaded_count = 0;         // Number of child entities deleted
+};
+
 /// Result of a workspace operation
 /// Result of a workspace operation
 struct WorkspaceResult {
 struct WorkspaceResult {
     bool success = false;
     bool success = false;
@@ -115,11 +139,29 @@ public:
     /// @return Result indicating success or error
     /// @return Result indicating success or error
     [[nodiscard]] auto DeleteWorkspace(const std::string& id) -> WorkspaceResult;
     [[nodiscard]] auto DeleteWorkspace(const std::string& id) -> WorkspaceResult;
 
 
+    /// Delete a workspace with cascade archiving
+    /// Archives the workspace and all child entities before soft deleting
+    /// @param id Workspace ID
+    /// @param options Delete options (archive, cascade, actor)
+    /// @return Result with archive ID and cascade count
+    [[nodiscard]] auto DeleteWorkspaceWithArchive(const std::string& id,
+                                                   const DeleteWorkspaceOptions& options) -> DeleteWorkspaceResult;
+
+    /// Set service references for cascade operations
+    void SetCascadeServices(ArchiveService* archive_service,
+                            PageService* page_service,
+                            ViewService* view_service,
+                            CollectionService* collection_service,
+                            DocumentService* document_service);
+
     /// Check if a workspace with the given name exists
     /// Check if a workspace with the given name exists
     /// @param name Workspace name
     /// @param name Workspace name
     /// @return True if workspace exists
     /// @return True if workspace exists
     [[nodiscard]] auto NameExists(const std::string& name) -> bool;
     [[nodiscard]] auto NameExists(const std::string& name) -> bool;
 
 
+    /// Set the event manager for emitting workspace events
+    void SetEventManager(EventManager* event_manager);
+
 private:
 private:
     /// Convert proto Document to Workspace struct
     /// Convert proto Document to Workspace struct
     [[nodiscard]] static auto DocumentToWorkspace(const ::smartbotic::database::Document& doc) -> Workspace;
     [[nodiscard]] static auto DocumentToWorkspace(const ::smartbotic::database::Document& doc) -> Workspace;
@@ -129,6 +171,14 @@ private:
 
 
     DatabaseClient& db_client_;
     DatabaseClient& db_client_;
     bool initialized_ = false;
     bool initialized_ = false;
+    EventManager* event_manager_ = nullptr;
+
+    // Service references for cascade operations
+    ArchiveService* archive_service_ = nullptr;
+    PageService* page_service_ = nullptr;
+    ViewService* view_service_ = nullptr;
+    CollectionService* collection_service_ = nullptr;
+    DocumentService* document_service_ = nullptr;
 };
 };
 
 
 }  // namespace smartbotic::webserver
 }  // namespace smartbotic::webserver

+ 21 - 0
webserver/include/smartbotic/webserver/ws_handler.hpp

@@ -26,6 +26,7 @@ enum class WsMessageType {
     SubscribeChat,  // Subscribe to LLM chat session
     SubscribeChat,  // Subscribe to LLM chat session
     UnsubscribeChat,// Unsubscribe from LLM chat session
     UnsubscribeChat,// Unsubscribe from LLM chat session
     Document,       // Document change event
     Document,       // Document change event
+    Workspace,      // Workspace change event (create/update/delete)
     ChatChunk,      // LLM streaming chunk event
     ChatChunk,      // LLM streaming chunk event
     ChatState,      // LLM chat state change (streaming start/stop)
     ChatState,      // LLM chat state change (streaming start/stop)
     Notification,   // System notification
     Notification,   // System notification
@@ -41,6 +42,13 @@ enum class DocumentAction {
     Delete
     Delete
 };
 };
 
 
+/// Workspace action types for events
+enum class WorkspaceAction {
+    Create,
+    Update,
+    Delete
+};
+
 /// Notification levels
 /// Notification levels
 enum class NotificationLevel {
 enum class NotificationLevel {
     Info,
     Info,
@@ -57,6 +65,9 @@ auto MessageTypeToString(WsMessageType type) -> std::string;
 /// Convert document action to string
 /// Convert document action to string
 auto DocumentActionToString(DocumentAction action) -> std::string;
 auto DocumentActionToString(DocumentAction action) -> std::string;
 
 
+/// Convert workspace action to string
+auto WorkspaceActionToString(WorkspaceAction action) -> std::string;
+
 /// Convert notification level to string
 /// Convert notification level to string
 auto NotificationLevelToString(NotificationLevel level) -> std::string;
 auto NotificationLevelToString(NotificationLevel level) -> std::string;
 
 
@@ -104,6 +115,16 @@ public:
                                  const nlohmann::json& data,
                                  const nlohmann::json& data,
                                  const std::function<void(const std::string&)>& broadcast_to_subscribed);
                                  const std::function<void(const std::string&)>& broadcast_to_subscribed);
 
 
+    /// Broadcast a workspace event to all authenticated connections
+    /// @param workspace_id Workspace ID
+    /// @param action Workspace action (create/update/delete)
+    /// @param data Workspace data
+    /// @param broadcast_callback Callback to broadcast to all authenticated connections
+    void BroadcastWorkspaceEvent(const std::string& workspace_id,
+                                  WorkspaceAction action,
+                                  const nlohmann::json& data,
+                                  const std::function<void(const std::string&)>& broadcast_callback);
+
     /// Send a notification to a specific connection
     /// Send a notification to a specific connection
     /// @param conn The connection
     /// @param conn The connection
     /// @param message Notification message
     /// @param message Notification message

+ 945 - 0
webserver/src/archive_service.cpp

@@ -0,0 +1,945 @@
+#include "smartbotic/webserver/archive_service.hpp"
+
+#include <chrono>
+#include <iomanip>
+#include <sstream>
+
+#include <spdlog/spdlog.h>
+
+#include "smartbotic/webserver/collection_service.hpp"
+#include "smartbotic/webserver/document_service.hpp"
+#include "smartbotic/webserver/page_service.hpp"
+#include "smartbotic/webserver/view_service.hpp"
+
+namespace smartbotic::webserver {
+
+auto ArchiveStatusToString(ArchiveStatus status) -> std::string {
+    switch (status) {
+        case ArchiveStatus::Active: return "active";
+        case ArchiveStatus::Expired: return "expired";
+        case ArchiveStatus::Restored: return "restored";
+        case ArchiveStatus::Exported: return "exported";
+    }
+    return "active";
+}
+
+auto StringToArchiveStatus(const std::string& str) -> ArchiveStatus {
+    if (str == "expired") return ArchiveStatus::Expired;
+    if (str == "restored") return ArchiveStatus::Restored;
+    if (str == "exported") return ArchiveStatus::Exported;
+    return ArchiveStatus::Active;
+}
+
+// EntityTypeToString is now defined in event_manager.cpp
+
+auto StringToEntityType(const std::string& str) -> EntityType {
+    if (str == "page") return EntityType::Page;
+    if (str == "view") return EntityType::View;
+    if (str == "collection") return EntityType::Collection;
+    if (str == "document") return EntityType::Document;
+    if (str == "session") return EntityType::Session;
+    if (str == "user") return EntityType::User;
+    if (str == "group") return EntityType::Group;
+    if (str == "membership") return EntityType::Membership;
+    return EntityType::Workspace;
+}
+
+namespace {
+
+auto SetStringValue(::smartbotic::database::MapValue* map, const std::string& key, const std::string& value) {
+    auto* field = &(*map->mutable_fields())[key];
+    field->set_string_value(value);
+}
+
+auto SetIntValue(::smartbotic::database::MapValue* map, const std::string& key, int64_t value) {
+    auto* field = &(*map->mutable_fields())[key];
+    field->set_int_value(value);
+}
+
+auto GetStringValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> std::string {
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_string_value()) {
+        return it->second.string_value();
+    }
+    return "";
+}
+
+auto GetIntValue(const ::smartbotic::database::MapValue& map, const std::string& key) -> int64_t {
+    auto it = map.fields().find(key);
+    if (it != map.fields().end() && it->second.has_int_value()) {
+        return it->second.int_value();
+    }
+    return 0;
+}
+
+}  // namespace
+
+ArchiveService::ArchiveService(DatabaseClient& db_client) : db_client_(db_client) {}
+
+ArchiveService::~ArchiveService() = default;
+
+ArchiveService::ArchiveService(ArchiveService&& other) noexcept
+    : db_client_(other.db_client_), initialized_(other.initialized_) {}
+
+auto ArchiveService::operator=(ArchiveService&& /*other*/) noexcept -> ArchiveService& {
+    return *this;
+}
+
+auto ArchiveService::Initialize() -> bool {
+    if (initialized_) {
+        return true;
+    }
+
+    auto* collection_service = db_client_.GetCollectionService();
+    if (collection_service == nullptr) {
+        spdlog::error("Collection service not available");
+        return false;
+    }
+
+    // Check if _archives collection exists
+    ::smartbotic::database::GetCollectionMetadataRequest get_req;
+    get_req.set_name(kArchivesCollection);
+
+    grpc::ClientContext get_ctx;
+    ::smartbotic::database::CollectionMetadata metadata;
+    auto status = collection_service->GetCollectionMetadata(&get_ctx, get_req, &metadata);
+
+    if (status.ok()) {
+        spdlog::info("System collection {} already exists", kArchivesCollection);
+        initialized_ = true;
+        return true;
+    }
+
+    // Collection doesn't exist, create it
+    ::smartbotic::database::CreateCollectionRequest create_req;
+    create_req.set_name(kArchivesCollection);
+
+    // Add indexes for common queries
+    auto* ws_index = create_req.add_indexes();
+    ws_index->set_collection(kArchivesCollection);
+    ws_index->set_index_name("workspace_id_idx");
+    ws_index->add_fields("workspace_id");
+    ws_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+    auto* parent_index = create_req.add_indexes();
+    parent_index->set_collection(kArchivesCollection);
+    parent_index->set_index_name("parent_archive_id_idx");
+    parent_index->add_fields("parent_archive_id");
+    parent_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+    auto* status_index = create_req.add_indexes();
+    status_index->set_collection(kArchivesCollection);
+    status_index->set_index_name("status_idx");
+    status_index->add_fields("status");
+    status_index->set_type(::smartbotic::database::INDEX_TYPE_HASH);
+
+    grpc::ClientContext create_ctx;
+    ::smartbotic::database::CollectionMetadata created_metadata;
+    status = collection_service->CreateCollection(&create_ctx, create_req, &created_metadata);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create {} collection: {}", kArchivesCollection, status.error_message());
+        return false;
+    }
+
+    spdlog::info("Created system collection {}", kArchivesCollection);
+    initialized_ = true;
+    return true;
+}
+
+void ArchiveService::SetServices(PageService* page_service,
+                                  ViewService* view_service,
+                                  CollectionService* collection_service,
+                                  DocumentService* document_service) {
+    page_service_ = page_service;
+    view_service_ = view_service;
+    collection_service_ = collection_service;
+    document_service_ = document_service;
+}
+
+auto ArchiveService::CreateArchive(const CreateArchiveRequest& request) -> ArchiveResult {
+    if (!initialized_) {
+        return {.success = false, .error = "Archive service not initialized", .archive = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return {.success = false, .error = "Document service not available", .archive = std::nullopt};
+    }
+
+    ArchiveInfo archive;
+    archive.entity_type = request.entity_type;
+    archive.entity_id = request.entity_id;
+    archive.workspace_id = request.workspace_id;
+    archive.parent_archive_id = request.parent_archive_id;
+    archive.data = request.data;
+    archive.children_count = 0;
+    archive.created_at = GetCurrentTimestamp();
+    archive.created_by = request.actor_id;
+    archive.expires_at = CalculateExpiration(request.retention_days);
+    archive.status = ArchiveStatus::Active;
+
+    ::smartbotic::database::CreateDocumentRequest create_req;
+    create_req.set_collection(kArchivesCollection);
+
+    auto* data = create_req.mutable_data();
+    SetStringValue(data, "entity_type", EntityTypeToString(archive.entity_type));
+    SetStringValue(data, "entity_id", archive.entity_id);
+    SetStringValue(data, "workspace_id", archive.workspace_id);
+    SetStringValue(data, "parent_archive_id", archive.parent_archive_id);
+    SetStringValue(data, "data", archive.data.dump());
+    SetIntValue(data, "children_count", archive.children_count);
+    SetStringValue(data, "created_at", archive.created_at);
+    SetStringValue(data, "created_by", archive.created_by);
+    SetStringValue(data, "expires_at", archive.expires_at);
+    SetStringValue(data, "status", ArchiveStatusToString(archive.status));
+    SetStringValue(data, "restored_at", "");
+    SetStringValue(data, "exported_at", "");
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document created_doc;
+    auto status = doc_service->CreateDocument(&ctx, create_req, &created_doc);
+
+    if (!status.ok()) {
+        spdlog::error("Failed to create archive: {}", status.error_message());
+        return {.success = false, .error = status.error_message(), .archive = std::nullopt};
+    }
+
+    archive.id = created_doc.id();
+
+    // Update parent's children count if this is a child archive
+    if (!request.parent_archive_id.empty()) {
+        UpdateChildrenCount(request.parent_archive_id, 1);
+    }
+
+    spdlog::info("Created archive {} for {} {}", archive.id,
+                 EntityTypeToString(archive.entity_type), archive.entity_id);
+    return {.success = true, .error = "", .archive = archive};
+}
+
+auto ArchiveService::GetArchive(const std::string& id) -> ArchiveResult {
+    if (!initialized_) {
+        return {.success = false, .error = "Archive service not initialized", .archive = std::nullopt};
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return {.success = false, .error = "Document service not available", .archive = std::nullopt};
+    }
+
+    ::smartbotic::database::GetDocumentRequest get_req;
+    get_req.set_collection(kArchivesCollection);
+    get_req.set_id(id);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document doc;
+    auto status = doc_service->GetDocument(&ctx, get_req, &doc);
+
+    if (!status.ok()) {
+        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
+            return {.success = false, .error = "Archive not found", .archive = std::nullopt};
+        }
+        return {.success = false, .error = status.error_message(), .archive = std::nullopt};
+    }
+
+    ArchiveInfo archive;
+    archive.id = doc.id();
+    archive.entity_type = StringToEntityType(GetStringValue(doc.data(), "entity_type"));
+    archive.entity_id = GetStringValue(doc.data(), "entity_id");
+    archive.workspace_id = GetStringValue(doc.data(), "workspace_id");
+    archive.parent_archive_id = GetStringValue(doc.data(), "parent_archive_id");
+    archive.children_count = GetIntValue(doc.data(), "children_count");
+    archive.created_at = GetStringValue(doc.data(), "created_at");
+    archive.created_by = GetStringValue(doc.data(), "created_by");
+    archive.expires_at = GetStringValue(doc.data(), "expires_at");
+    archive.status = StringToArchiveStatus(GetStringValue(doc.data(), "status"));
+    archive.restored_at = GetStringValue(doc.data(), "restored_at");
+    archive.exported_at = GetStringValue(doc.data(), "exported_at");
+
+    // Parse data JSON
+    std::string data_str = GetStringValue(doc.data(), "data");
+    if (!data_str.empty()) {
+        try {
+            archive.data = nlohmann::json::parse(data_str);
+        } catch (const nlohmann::json::parse_error&) {
+            archive.data = nlohmann::json::object();
+        }
+    }
+
+    return {.success = true, .error = "", .archive = archive};
+}
+
+auto ArchiveService::ListArchives(const ArchiveListRequest& request) -> ArchiveListResult {
+    if (!initialized_) {
+        return {.success = false, .error = "Archive service not initialized", .archives = {}, .total_count = 0};
+    }
+
+    auto* query_service = db_client_.GetQueryService();
+    if (query_service == nullptr) {
+        return {.success = false, .error = "Query service not available", .archives = {}, .total_count = 0};
+    }
+
+    ::smartbotic::database::QueryRequest query_req;
+    query_req.set_collection(kArchivesCollection);
+    query_req.set_limit(request.limit);
+    query_req.set_offset(request.offset);
+
+    // Build composite filter
+    auto* filter = query_req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    // Filter by workspace if specified
+    if (!request.workspace_id.empty()) {
+        auto* ws_filter = composite->add_filters()->mutable_field();
+        ws_filter->set_field("workspace_id");
+        ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        ws_filter->mutable_value()->set_string_value(request.workspace_id);
+    }
+
+    // Filter by entity type if not including all types
+    if (!request.include_all_types) {
+        auto* type_filter = composite->add_filters()->mutable_field();
+        type_filter->set_field("entity_type");
+        type_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        type_filter->mutable_value()->set_string_value(EntityTypeToString(request.entity_type));
+    }
+
+    // Filter by parent archive ID
+    if (!request.parent_archive_id.empty()) {
+        auto* parent_filter = composite->add_filters()->mutable_field();
+        parent_filter->set_field("parent_archive_id");
+        parent_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        parent_filter->mutable_value()->set_string_value(request.parent_archive_id);
+    }
+
+    // Filter by status (exclude expired unless requested)
+    if (!request.include_expired) {
+        auto* status_filter = composite->add_filters()->mutable_field();
+        status_filter->set_field("status");
+        status_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        status_filter->mutable_value()->set_string_value("active");
+    }
+
+    // Order by created_at descending
+    auto* order = query_req.add_order_by();
+    order->set_field("created_at");
+    order->set_direction(::smartbotic::database::SORT_DIRECTION_DESCENDING);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::QueryResponse response;
+    auto status = query_service->Query(&ctx, query_req, &response);
+
+    if (!status.ok()) {
+        return {.success = false, .error = status.error_message(), .archives = {}, .total_count = 0};
+    }
+
+    ArchiveListResult result;
+    result.success = true;
+    result.total_count = response.total_count();
+
+    for (const auto& doc : response.documents()) {
+        ArchiveInfo archive;
+        archive.id = doc.id();
+        archive.entity_type = StringToEntityType(GetStringValue(doc.data(), "entity_type"));
+        archive.entity_id = GetStringValue(doc.data(), "entity_id");
+        archive.workspace_id = GetStringValue(doc.data(), "workspace_id");
+        archive.parent_archive_id = GetStringValue(doc.data(), "parent_archive_id");
+        archive.children_count = GetIntValue(doc.data(), "children_count");
+        archive.created_at = GetStringValue(doc.data(), "created_at");
+        archive.created_by = GetStringValue(doc.data(), "created_by");
+        archive.expires_at = GetStringValue(doc.data(), "expires_at");
+        archive.status = StringToArchiveStatus(GetStringValue(doc.data(), "status"));
+        archive.restored_at = GetStringValue(doc.data(), "restored_at");
+        archive.exported_at = GetStringValue(doc.data(), "exported_at");
+
+        // Parse data JSON
+        std::string data_str = GetStringValue(doc.data(), "data");
+        if (!data_str.empty()) {
+            try {
+                archive.data = nlohmann::json::parse(data_str);
+            } catch (const nlohmann::json::parse_error&) {
+                archive.data = nlohmann::json::object();
+            }
+        }
+
+        result.archives.push_back(archive);
+    }
+
+    return result;
+}
+
+auto ArchiveService::RestoreArchive(const std::string& archive_id,
+                                     const std::string& actor_id,
+                                     bool restore_children) -> RestoreResult {
+    // Get the archive
+    auto archive_result = GetArchive(archive_id);
+    if (!archive_result.success || !archive_result.archive) {
+        return {.success = false, .error = archive_result.error, .restored_entity_id = "", .restored_children_count = 0};
+    }
+
+    auto& archive = *archive_result.archive;
+    if (archive.status != ArchiveStatus::Active) {
+        return {.success = false, .error = "Archive is not active", .restored_entity_id = "", .restored_children_count = 0};
+    }
+
+    RestoreResult result;
+    result.success = true;
+    result.restored_children_count = 0;
+
+    // Restore based on entity type
+    switch (archive.entity_type) {
+        case EntityType::Workspace:
+            // Workspace restore would need to call workspace_service
+            // For now, return the data - caller can decide what to do
+            result.restored_entity_id = archive.entity_id;
+            break;
+
+        case EntityType::Page:
+            if (page_service_ != nullptr) {
+                // Create page from archived data
+                CreatePageRequest page_req;
+                page_req.workspace_id = archive.data.value("workspace_id", "");
+                page_req.name = archive.data.value("name", "");
+                page_req.slug = archive.data.value("slug", "");
+                page_req.created_by = actor_id;
+                // Parse layout and settings from archive.data if needed
+                auto page_result = page_service_->CreatePage(page_req);
+                if (page_result.success && page_result.page) {
+                    result.restored_entity_id = page_result.page->id;
+                } else {
+                    result.success = false;
+                    result.error = page_result.error;
+                }
+            }
+            break;
+
+        case EntityType::View:
+            if (view_service_ != nullptr) {
+                CreateViewRequest view_req;
+                view_req.workspace_id = archive.data.value("workspace_id", "");
+                view_req.name = archive.data.value("name", "");
+                view_req.collection_name = archive.data.value("collection_name", "");
+                // Parse schema and settings from archive.data if needed
+                auto view_result = view_service_->CreateView(view_req);
+                if (view_result.success && view_result.view) {
+                    result.restored_entity_id = view_result.view->id;
+                } else {
+                    result.success = false;
+                    result.error = view_result.error;
+                }
+            }
+            break;
+
+        case EntityType::Collection:
+            if (collection_service_ != nullptr) {
+                CreateCollectionRequest coll_req;
+                coll_req.workspace_id = archive.workspace_id;
+                coll_req.name = archive.entity_id;  // Collection name is stored as entity_id
+                coll_req.actor_id = actor_id;
+                auto coll_result = collection_service_->CreateCollection(coll_req);
+                if (coll_result.success && coll_result.collection) {
+                    result.restored_entity_id = coll_result.collection->name;
+                } else {
+                    result.success = false;
+                    result.error = coll_result.error;
+                }
+            }
+            break;
+
+        case EntityType::Document:
+            if (document_service_ != nullptr) {
+                webserver::CreateDocumentRequest doc_req;
+                doc_req.workspace_id = archive.workspace_id;
+                doc_req.collection = archive.data.value("collection", "");
+                doc_req.data = archive.data.value("data", nlohmann::json::object());
+                doc_req.user_id = actor_id;
+                auto doc_result = document_service_->CreateDocument(doc_req);
+                if (doc_result.success && doc_result.document) {
+                    result.restored_entity_id = doc_result.document->id;
+                } else {
+                    result.success = false;
+                    result.error = doc_result.error;
+                }
+            }
+            break;
+
+        case EntityType::Session:
+            // Session restore would need LLM session service
+            result.restored_entity_id = archive.entity_id;
+            break;
+
+        case EntityType::User:
+        case EntityType::Group:
+        case EntityType::Membership:
+            // These types are not typically archived/restored via this service
+            result.restored_entity_id = archive.entity_id;
+            break;
+    }
+
+    // Restore children if requested
+    if (result.success && restore_children && archive.children_count > 0) {
+        auto children = GetChildArchives(archive_id);
+        for (const auto& child : children.archives) {
+            auto child_result = RestoreArchive(child.id, actor_id, true);
+            if (child_result.success) {
+                result.restored_children_count += 1 + child_result.restored_children_count;
+            }
+        }
+    }
+
+    // Mark archive as restored
+    if (result.success) {
+        auto* doc_service = db_client_.GetDocumentService();
+        if (doc_service != nullptr) {
+            ::smartbotic::database::UpdateDocumentRequest update_req;
+            update_req.set_collection(kArchivesCollection);
+            update_req.set_id(archive_id);
+            update_req.set_merge(true);
+
+            auto* data = update_req.mutable_data();
+            SetStringValue(data, "status", ArchiveStatusToString(ArchiveStatus::Restored));
+            SetStringValue(data, "restored_at", GetCurrentTimestamp());
+
+            grpc::ClientContext ctx;
+            ::smartbotic::database::Document updated_doc;
+            doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+        }
+    }
+
+    return result;
+}
+
+auto ArchiveService::ExportArchive(const std::string& archive_id) -> ExportResult {
+    auto archive_result = GetArchive(archive_id);
+    if (!archive_result.success || !archive_result.archive) {
+        return {.success = false, .error = archive_result.error, .export_data = nlohmann::json::object()};
+    }
+
+    auto& archive = *archive_result.archive;
+
+    nlohmann::json export_data;
+    export_data["id"] = archive.id;
+    export_data["entity_type"] = EntityTypeToString(archive.entity_type);
+    export_data["entity_id"] = archive.entity_id;
+    export_data["workspace_id"] = archive.workspace_id;
+    export_data["data"] = archive.data;
+    export_data["created_at"] = archive.created_at;
+    export_data["created_by"] = archive.created_by;
+    export_data["exported_at"] = GetCurrentTimestamp();
+
+    // Include children
+    if (archive.children_count > 0) {
+        nlohmann::json children = nlohmann::json::array();
+        auto children_result = GetChildArchives(archive_id);
+        for (const auto& child : children_result.archives) {
+            auto child_export = ExportArchive(child.id);
+            if (child_export.success) {
+                children.push_back(child_export.export_data);
+            }
+        }
+        export_data["children"] = children;
+    }
+
+    // Mark archive as exported
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service != nullptr) {
+        ::smartbotic::database::UpdateDocumentRequest update_req;
+        update_req.set_collection(kArchivesCollection);
+        update_req.set_id(archive_id);
+        update_req.set_merge(true);
+
+        auto* data = update_req.mutable_data();
+        SetStringValue(data, "exported_at", GetCurrentTimestamp());
+
+        grpc::ClientContext ctx;
+        ::smartbotic::database::Document updated_doc;
+        doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+    }
+
+    return {.success = true, .error = "", .export_data = export_data};
+}
+
+auto ArchiveService::DeleteArchive(const std::string& archive_id,
+                                    bool delete_children) -> ArchiveResult {
+    auto archive_result = GetArchive(archive_id);
+    if (!archive_result.success || !archive_result.archive) {
+        return archive_result;
+    }
+
+    auto& archive = *archive_result.archive;
+
+    // Delete children first if requested
+    if (delete_children && archive.children_count > 0) {
+        auto children = GetChildArchives(archive_id);
+        for (const auto& child : children.archives) {
+            (void)DeleteArchive(child.id, true);
+        }
+    }
+
+    // Delete the archive
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return {.success = false, .error = "Document service not available", .archive = std::nullopt};
+    }
+
+    ::smartbotic::database::DeleteDocumentRequest delete_req;
+    delete_req.set_collection(kArchivesCollection);
+    delete_req.set_id(archive_id);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::DeleteDocumentResponse delete_resp;
+    auto status = doc_service->DeleteDocument(&ctx, delete_req, &delete_resp);
+
+    if (!status.ok()) {
+        return {.success = false, .error = status.error_message(), .archive = std::nullopt};
+    }
+
+    // Update parent's children count if this had a parent
+    if (!archive.parent_archive_id.empty()) {
+        UpdateChildrenCount(archive.parent_archive_id, -1);
+    }
+
+    spdlog::info("Deleted archive {} for {} {}", archive_id,
+                 EntityTypeToString(archive.entity_type), archive.entity_id);
+    return {.success = true, .error = "", .archive = std::nullopt};
+}
+
+auto ArchiveService::CleanupExpiredArchives() -> CleanupResult {
+    if (!initialized_) {
+        return {.success = false, .error = "Archive service not initialized", .cleaned_count = 0};
+    }
+
+    // List all archives with expired status or past expiration date
+    ArchiveListRequest list_req;
+    list_req.include_all_types = true;
+    list_req.include_expired = true;
+    list_req.limit = 1000;
+
+    auto list_result = ListArchives(list_req);
+    if (!list_result.success) {
+        return {.success = false, .error = list_result.error, .cleaned_count = 0};
+    }
+
+    int cleaned = 0;
+    for (const auto& archive : list_result.archives) {
+        // Check if expired
+        if (IsExpired(archive.expires_at) && archive.status == ArchiveStatus::Active) {
+            // Only delete top-level archives (they will cascade delete children)
+            if (archive.parent_archive_id.empty()) {
+                auto delete_result = DeleteArchive(archive.id, true);
+                if (delete_result.success) {
+                    cleaned += 1 + static_cast<int>(archive.children_count);
+                }
+            }
+        }
+    }
+
+    spdlog::info("Cleaned up {} expired archives", cleaned);
+    return {.success = true, .error = "", .cleaned_count = cleaned};
+}
+
+auto ArchiveService::ArchiveWorkspace(const std::string& workspace_id,
+                                       const nlohmann::json& workspace_data,
+                                       const std::string& actor_id,
+                                       int retention_days) -> ArchiveResult {
+    // Create workspace archive
+    CreateArchiveRequest req;
+    req.entity_type = EntityType::Workspace;
+    req.entity_id = workspace_id;
+    req.workspace_id = workspace_id;
+    req.data = workspace_data;
+    req.actor_id = actor_id;
+    req.retention_days = retention_days;
+
+    auto ws_archive = CreateArchive(req);
+    if (!ws_archive.success) {
+        return ws_archive;
+    }
+
+    std::string parent_archive_id = ws_archive.archive->id;
+
+    // Archive all pages
+    if (page_service_ != nullptr) {
+        auto pages_result = page_service_->ListPages(workspace_id);
+        if (pages_result.success) {
+            for (const auto& page : pages_result.pages) {
+                nlohmann::json page_data;
+                page_data["id"] = page.id;
+                page_data["workspace_id"] = page.workspace_id;
+                page_data["name"] = page.name;
+                page_data["slug"] = page.slug;
+                page_data["created_at"] = page.created_at;
+                page_data["updated_at"] = page.updated_at;
+                page_data["created_by"] = page.created_by;
+
+                (void)ArchivePage(page.id, page_data, workspace_id, parent_archive_id, actor_id, retention_days);
+            }
+        }
+    }
+
+    // Archive all views
+    if (view_service_ != nullptr) {
+        auto views_result = view_service_->ListViews(workspace_id);
+        if (views_result.success) {
+            for (const auto& view : views_result.views) {
+                nlohmann::json view_data;
+                view_data["id"] = view.id;
+                view_data["workspace_id"] = view.workspace_id;
+                view_data["name"] = view.name;
+                view_data["collection_name"] = view.collection_name;
+                view_data["created_at"] = view.created_at;
+                view_data["updated_at"] = view.updated_at;
+
+                (void)ArchiveView(view.id, view_data, workspace_id, parent_archive_id, actor_id, retention_days);
+            }
+        }
+    }
+
+    // Archive all collections and their documents
+    if (collection_service_ != nullptr) {
+        auto collections_result = collection_service_->ListCollections(workspace_id);
+        if (collections_result.success) {
+            for (const auto& coll : collections_result.collections) {
+                nlohmann::json coll_data;
+                coll_data["name"] = coll.name;
+                coll_data["workspace_id"] = coll.workspace_id;
+                coll_data["document_count"] = coll.document_count;
+
+                auto coll_archive = ArchiveCollection(coll.name, coll_data, workspace_id,
+                                                       parent_archive_id, actor_id, retention_days);
+
+                // Archive documents in this collection
+                if (coll_archive.success && document_service_ != nullptr) {
+                    DocumentQuery doc_query;
+                    doc_query.workspace_id = workspace_id;
+                    doc_query.collection = coll.name;
+                    doc_query.limit = 10000;  // Get all documents
+
+                    auto docs_result = document_service_->ListDocuments(doc_query);
+                    if (docs_result.success) {
+                        for (const auto& doc : docs_result.documents) {
+                            nlohmann::json doc_data;
+                            doc_data["id"] = doc.id;
+                            doc_data["collection"] = doc.collection;
+                            doc_data["data"] = doc.data;
+                            doc_data["created_at"] = doc.created_at;
+                            doc_data["updated_at"] = doc.updated_at;
+
+                            (void)ArchiveDocument(doc.id, doc_data, coll.name, workspace_id,
+                                           coll_archive.archive->id, actor_id, retention_days);
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    return ws_archive;
+}
+
+auto ArchiveService::ArchivePage(const std::string& page_id,
+                                  const nlohmann::json& page_data,
+                                  const std::string& workspace_id,
+                                  const std::string& parent_archive_id,
+                                  const std::string& actor_id,
+                                  int retention_days) -> ArchiveResult {
+    CreateArchiveRequest req;
+    req.entity_type = EntityType::Page;
+    req.entity_id = page_id;
+    req.workspace_id = workspace_id;
+    req.parent_archive_id = parent_archive_id;
+    req.data = page_data;
+    req.actor_id = actor_id;
+    req.retention_days = retention_days;
+    return CreateArchive(req);
+}
+
+auto ArchiveService::ArchiveView(const std::string& view_id,
+                                  const nlohmann::json& view_data,
+                                  const std::string& workspace_id,
+                                  const std::string& parent_archive_id,
+                                  const std::string& actor_id,
+                                  int retention_days) -> ArchiveResult {
+    CreateArchiveRequest req;
+    req.entity_type = EntityType::View;
+    req.entity_id = view_id;
+    req.workspace_id = workspace_id;
+    req.parent_archive_id = parent_archive_id;
+    req.data = view_data;
+    req.actor_id = actor_id;
+    req.retention_days = retention_days;
+    return CreateArchive(req);
+}
+
+auto ArchiveService::ArchiveCollection(const std::string& collection_name,
+                                        const nlohmann::json& collection_data,
+                                        const std::string& workspace_id,
+                                        const std::string& parent_archive_id,
+                                        const std::string& actor_id,
+                                        int retention_days) -> ArchiveResult {
+    CreateArchiveRequest req;
+    req.entity_type = EntityType::Collection;
+    req.entity_id = collection_name;
+    req.workspace_id = workspace_id;
+    req.parent_archive_id = parent_archive_id;
+    req.data = collection_data;
+    req.actor_id = actor_id;
+    req.retention_days = retention_days;
+    return CreateArchive(req);
+}
+
+auto ArchiveService::ArchiveDocument(const std::string& document_id,
+                                      const nlohmann::json& document_data,
+                                      const std::string& collection_name,
+                                      const std::string& workspace_id,
+                                      const std::string& parent_archive_id,
+                                      const std::string& actor_id,
+                                      int retention_days) -> ArchiveResult {
+    CreateArchiveRequest req;
+    req.entity_type = EntityType::Document;
+    req.entity_id = document_id;
+    req.workspace_id = workspace_id;
+    req.parent_archive_id = parent_archive_id;
+    req.data = document_data;
+    req.actor_id = actor_id;
+    req.retention_days = retention_days;
+    return CreateArchive(req);
+}
+
+auto ArchiveService::ArchiveSession(const std::string& session_id,
+                                     const nlohmann::json& session_data,
+                                     const std::string& workspace_id,
+                                     const std::string& parent_archive_id,
+                                     const std::string& actor_id,
+                                     int retention_days) -> ArchiveResult {
+    CreateArchiveRequest req;
+    req.entity_type = EntityType::Session;
+    req.entity_id = session_id;
+    req.workspace_id = workspace_id;
+    req.parent_archive_id = parent_archive_id;
+    req.data = session_data;
+    req.actor_id = actor_id;
+    req.retention_days = retention_days;
+    return CreateArchive(req);
+}
+
+auto ArchiveService::ArchiveToJson(const ArchiveInfo& archive) -> nlohmann::json {
+    return {
+        {"id", archive.id},
+        {"entity_type", EntityTypeToString(archive.entity_type)},
+        {"entity_id", archive.entity_id},
+        {"workspace_id", archive.workspace_id},
+        {"parent_archive_id", archive.parent_archive_id},
+        {"data", archive.data},
+        {"children_count", archive.children_count},
+        {"created_at", archive.created_at},
+        {"created_by", archive.created_by},
+        {"expires_at", archive.expires_at},
+        {"status", ArchiveStatusToString(archive.status)},
+        {"restored_at", archive.restored_at},
+        {"exported_at", archive.exported_at}
+    };
+}
+
+auto ArchiveService::JsonToArchive(const nlohmann::json& json) -> ArchiveInfo {
+    ArchiveInfo archive;
+    archive.id = json.value("id", "");
+    archive.entity_type = StringToEntityType(json.value("entity_type", "workspace"));
+    archive.entity_id = json.value("entity_id", "");
+    archive.workspace_id = json.value("workspace_id", "");
+    archive.parent_archive_id = json.value("parent_archive_id", "");
+    archive.data = json.value("data", nlohmann::json::object());
+    archive.children_count = json.value("children_count", 0);
+    archive.created_at = json.value("created_at", "");
+    archive.created_by = json.value("created_by", "");
+    archive.expires_at = json.value("expires_at", "");
+    archive.status = StringToArchiveStatus(json.value("status", "active"));
+    archive.restored_at = json.value("restored_at", "");
+    archive.exported_at = json.value("exported_at", "");
+    return archive;
+}
+
+auto ArchiveService::GetCurrentTimestamp() -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto time = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) % 1000;
+
+    std::tm tm{};
+    gmtime_r(&time, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << '.' << std::setfill('0') << std::setw(3) << ms.count() << 'Z';
+    return oss.str();
+}
+
+auto ArchiveService::CalculateExpiration(int retention_days) -> std::string {
+    auto now = std::chrono::system_clock::now();
+    auto expiration = now + std::chrono::hours(24 * retention_days);
+    auto time = std::chrono::system_clock::to_time_t(expiration);
+
+    std::tm tm{};
+    gmtime_r(&time, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << ".000Z";
+    return oss.str();
+}
+
+auto ArchiveService::IsExpired(const std::string& expires_at) -> bool {
+    if (expires_at.empty()) {
+        return false;
+    }
+
+    // Parse the expiration timestamp
+    std::tm tm{};
+    std::istringstream ss(expires_at);
+    ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
+
+    if (ss.fail()) {
+        return false;
+    }
+
+    auto expiration_time = std::chrono::system_clock::from_time_t(timegm(&tm));
+    auto now = std::chrono::system_clock::now();
+
+    return now > expiration_time;
+}
+
+auto ArchiveService::UpdateChildrenCount(const std::string& archive_id, int64_t delta) -> bool {
+    auto archive_result = GetArchive(archive_id);
+    if (!archive_result.success || !archive_result.archive) {
+        return false;
+    }
+
+    auto* doc_service = db_client_.GetDocumentService();
+    if (doc_service == nullptr) {
+        return false;
+    }
+
+    ::smartbotic::database::UpdateDocumentRequest update_req;
+    update_req.set_collection(kArchivesCollection);
+    update_req.set_id(archive_id);
+    update_req.set_merge(true);
+
+    auto* data = update_req.mutable_data();
+    SetIntValue(data, "children_count", archive_result.archive->children_count + delta);
+
+    grpc::ClientContext ctx;
+    ::smartbotic::database::Document updated_doc;
+    auto status = doc_service->UpdateDocument(&ctx, update_req, &updated_doc);
+
+    return status.ok();
+}
+
+auto ArchiveService::GetChildArchives(const std::string& parent_archive_id) -> ArchiveListResult {
+    ArchiveListRequest req;
+    req.parent_archive_id = parent_archive_id;
+    req.include_all_types = true;
+    req.limit = 10000;  // Get all children
+    return ListArchives(req);
+}
+
+}  // namespace smartbotic::webserver

+ 111 - 10
webserver/src/document_service.cpp

@@ -6,6 +6,8 @@
 
 
 #include <spdlog/spdlog.h>
 #include <spdlog/spdlog.h>
 
 
+#include "smartbotic/webserver/event_manager.hpp"
+
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
 namespace {
 namespace {
@@ -40,6 +42,10 @@ DocumentService::DocumentService(DatabaseClient& db_client) : db_client_(db_clie
 
 
 DocumentService::~DocumentService() = default;
 DocumentService::~DocumentService() = default;
 
 
+void DocumentService::SetEventManager(EventManager* event_manager) {
+    event_manager_ = event_manager;
+}
+
 DocumentService::DocumentService(DocumentService&&) noexcept = default;
 DocumentService::DocumentService(DocumentService&&) noexcept = default;
 auto DocumentService::operator=(DocumentService&& /*other*/) noexcept -> DocumentService& {
 auto DocumentService::operator=(DocumentService&& /*other*/) noexcept -> DocumentService& {
     // Cannot reassign reference member, so this is essentially a no-op
     // Cannot reassign reference member, so this is essentially a no-op
@@ -64,6 +70,8 @@ auto DocumentService::CreateDocument(const CreateDocumentRequest& request) -> Do
         data_with_ownership["_created_by"] = request.user_id;
         data_with_ownership["_created_by"] = request.user_id;
         data_with_ownership["_updated_by"] = request.user_id;
         data_with_ownership["_updated_by"] = request.user_id;
     }
     }
+    // Initialize soft delete field (empty string = not deleted)
+    data_with_ownership["_deleted_at"] = "";
 
 
     *req.mutable_data() = JsonToMapValue(data_with_ownership);
     *req.mutable_data() = JsonToMapValue(data_with_ownership);
 
 
@@ -81,11 +89,26 @@ auto DocumentService::CreateDocument(const CreateDocumentRequest& request) -> Do
 
 
     auto info = ProtoToDocumentInfo(request.workspace_id, request.collection, resp);
     auto info = ProtoToDocumentInfo(request.workspace_id, request.collection, resp);
     spdlog::debug("Created document {} in collection {}", info.id, request.collection);
     spdlog::debug("Created document {} in collection {}", info.id, request.collection);
+
+    // Emit document created event
+    if (event_manager_ != nullptr) {
+        nlohmann::json doc_json = {
+            {"id", info.id},
+            {"collection", info.collection},
+            {"workspace_id", info.workspace_id},
+            {"data", info.data},
+            {"created_at", info.created_at},
+            {"updated_at", info.updated_at}
+        };
+        event_manager_->EmitDocumentEvent(EventAction::Create, info.id, info.workspace_id,
+                                           info.collection, doc_json);
+    }
+
     return {.success = true, .error = "", .document = info};
     return {.success = true, .error = "", .document = info};
 }
 }
 
 
 auto DocumentService::GetDocument(const std::string& workspace_id, const std::string& collection,
 auto DocumentService::GetDocument(const std::string& workspace_id, const std::string& collection,
-                                   const std::string& id) -> DocumentResult {
+                                   const std::string& id, bool include_deleted) -> DocumentResult {
     std::string internal_collection = GetInternalCollectionName(workspace_id, collection);
     std::string internal_collection = GetInternalCollectionName(workspace_id, collection);
 
 
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
@@ -105,6 +128,12 @@ auto DocumentService::GetDocument(const std::string& workspace_id, const std::st
     }
     }
 
 
     auto info = ProtoToDocumentInfo(workspace_id, collection, resp);
     auto info = ProtoToDocumentInfo(workspace_id, collection, resp);
+
+    // Check if deleted
+    if (!include_deleted && info.IsDeleted()) {
+        return {.success = false, .error = "Document not found", .document = std::nullopt};
+    }
+
     return {.success = true, .error = "", .document = info};
     return {.success = true, .error = "", .document = info};
 }
 }
 
 
@@ -129,7 +158,9 @@ auto DocumentService::ListDocuments(const DocumentQuery& query) -> DocumentListR
     }
     }
 
 
     // Build filter if provided
     // Build filter if provided
-    bool has_filter = (!query.filter.empty() && query.filter.is_object()) || !query.owner_filter.empty();
+    bool has_filter = (!query.filter.empty() && query.filter.is_object()) ||
+                      !query.owner_filter.empty() ||
+                      !query.include_deleted;
     if (has_filter) {
     if (has_filter) {
         auto* filter = req.mutable_filter();
         auto* filter = req.mutable_filter();
         auto* composite = filter->mutable_composite();
         auto* composite = filter->mutable_composite();
@@ -152,6 +183,24 @@ auto DocumentService::ListDocuments(const DocumentQuery& query) -> DocumentListR
             owner_field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
             owner_field_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
             owner_field_filter->mutable_value()->set_string_value(query.owner_filter);
             owner_field_filter->mutable_value()->set_string_value(query.owner_filter);
         }
         }
+
+        // Filter out deleted documents unless include_deleted is true
+        // Match documents where _deleted_at is empty OR doesn't exist (IS NULL)
+        if (!query.include_deleted) {
+            auto* deleted_composite = composite->add_filters()->mutable_composite();
+            deleted_composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_OR);
+
+            // _deleted_at == "" (explicitly set to empty string)
+            auto* empty_filter = deleted_composite->add_filters()->mutable_field();
+            empty_filter->set_field("_deleted_at");
+            empty_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+            empty_filter->mutable_value()->set_string_value("");
+
+            // _deleted_at IS NULL (field doesn't exist)
+            auto* null_filter = deleted_composite->add_filters()->mutable_field();
+            null_filter->set_field("_deleted_at");
+            null_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_IS_NULL);
+        }
     }
     }
 
 
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
@@ -210,6 +259,21 @@ auto DocumentService::UpdateDocument(const UpdateDocumentRequest& request) -> Do
 
 
     auto info = ProtoToDocumentInfo(request.workspace_id, request.collection, resp);
     auto info = ProtoToDocumentInfo(request.workspace_id, request.collection, resp);
     spdlog::debug("Updated document {} in collection {}", info.id, request.collection);
     spdlog::debug("Updated document {} in collection {}", info.id, request.collection);
+
+    // Emit document updated event
+    if (event_manager_ != nullptr) {
+        nlohmann::json doc_json = {
+            {"id", info.id},
+            {"collection", info.collection},
+            {"workspace_id", info.workspace_id},
+            {"data", info.data},
+            {"created_at", info.created_at},
+            {"updated_at", info.updated_at}
+        };
+        event_manager_->EmitDocumentEvent(EventAction::Update, info.id, info.workspace_id,
+                                           info.collection, doc_json);
+    }
+
     return {.success = true, .error = "", .document = info};
     return {.success = true, .error = "", .document = info};
 }
 }
 
 
@@ -217,23 +281,54 @@ auto DocumentService::DeleteDocument(const std::string& workspace_id, const std:
                                       const std::string& id) -> DocumentResult {
                                       const std::string& id) -> DocumentResult {
     std::string internal_collection = GetInternalCollectionName(workspace_id, collection);
     std::string internal_collection = GetInternalCollectionName(workspace_id, collection);
 
 
+    // Verify document exists
+    auto existing = GetDocument(workspace_id, collection, id, false);
+    if (!existing.success || !existing.document) {
+        return {.success = false, .error = "Document not found", .document = std::nullopt};
+    }
+
+    // Get current timestamp
+    auto now = std::chrono::system_clock::now();
+    auto time_t_val = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+        now.time_since_epoch()) % 1000;
+
+    std::tm tm{};
+    gmtime_r(&time_t_val, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z";
+    std::string current_timestamp = oss.str();
+
+    // Soft delete by setting _deleted_at in document data
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
-    ::smartbotic::database::DeleteDocumentRequest req;
-    ::smartbotic::database::DeleteDocumentResponse resp;
+    ::smartbotic::database::UpdateDocumentRequest req;
+    ::smartbotic::database::Document resp;
 
 
     req.set_collection(internal_collection);
     req.set_collection(internal_collection);
     req.set_id(id);
     req.set_id(id);
+    req.set_merge(true);
+
+    auto* data = req.mutable_data();
+    ::smartbotic::database::Value deleted_value;
+    deleted_value.set_string_value(current_timestamp);
+    (*data->mutable_fields())["_deleted_at"] = deleted_value;
 
 
-    auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
+    auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp);
     if (!status.ok()) {
     if (!status.ok()) {
-        if (status.error_code() == grpc::StatusCode::NOT_FOUND) {
-            return {.success = false, .error = "Document not found", .document = std::nullopt};
-        }
-        spdlog::error("Failed to delete document {} from {}: {}", id, internal_collection, status.error_message());
+        spdlog::error("Failed to soft delete document {} from {}: {}", id, internal_collection, status.error_message());
         return {.success = false, .error = "Failed to delete document: " + status.error_message(), .document = std::nullopt};
         return {.success = false, .error = "Failed to delete document: " + status.error_message(), .document = std::nullopt};
     }
     }
 
 
-    spdlog::debug("Deleted document {} from collection {}", id, collection);
+    spdlog::debug("Soft deleted document {} from collection {}", id, collection);
+
+    // Emit document deleted event
+    if (event_manager_ != nullptr) {
+        event_manager_->EmitDocumentEvent(EventAction::Delete, id, workspace_id,
+                                           collection, nlohmann::json{});
+    }
+
     return {.success = true, .error = "", .document = std::nullopt};
     return {.success = true, .error = "", .document = std::nullopt};
 }
 }
 
 
@@ -257,6 +352,12 @@ auto DocumentService::ProtoToDocumentInfo(const std::string& workspace_id,
     info.created_at = TimestampToString(doc.created_at());
     info.created_at = TimestampToString(doc.created_at());
     info.updated_at = TimestampToString(doc.updated_at());
     info.updated_at = TimestampToString(doc.updated_at());
     info.version = doc.version();
     info.version = doc.version();
+
+    // Extract deleted_at from data if present
+    if (info.data.contains("_deleted_at") && info.data["_deleted_at"].is_string()) {
+        info.deleted_at = info.data["_deleted_at"].get<std::string>();
+    }
+
     return info;
     return info;
 }
 }
 
 

+ 196 - 0
webserver/src/event_manager.cpp

@@ -0,0 +1,196 @@
+#include "smartbotic/webserver/event_manager.hpp"
+
+#include <spdlog/spdlog.h>
+
+namespace smartbotic::webserver {
+
+auto EntityTypeToString(EntityType type) -> std::string {
+    switch (type) {
+        case EntityType::Workspace:  return "workspace";
+        case EntityType::Page:       return "page";
+        case EntityType::View:       return "view";
+        case EntityType::Collection: return "collection";
+        case EntityType::Document:   return "document";
+        case EntityType::Session:    return "session";
+        case EntityType::User:       return "user";
+        case EntityType::Group:      return "group";
+        case EntityType::Membership: return "membership";
+        default:                     return "unknown";
+    }
+}
+
+auto EventActionToString(EventAction action) -> std::string {
+    switch (action) {
+        case EventAction::Create: return "create";
+        case EventAction::Update: return "update";
+        case EventAction::Delete: return "delete";
+        default:                  return "unknown";
+    }
+}
+
+auto EventManager::AddListener(EventListener listener) -> size_t {
+    std::lock_guard<std::mutex> lock(mutex_);
+    size_t id = next_listener_id_++;
+    listeners_.emplace_back(id, std::move(listener));
+    return id;
+}
+
+void EventManager::RemoveListener(size_t listener_id) {
+    std::lock_guard<std::mutex> lock(mutex_);
+    listeners_.erase(
+        std::remove_if(listeners_.begin(), listeners_.end(),
+                       [listener_id](const auto& pair) { return pair.first == listener_id; }),
+        listeners_.end());
+}
+
+void EventManager::Emit(const EntityEvent& event) {
+    std::vector<EventListener> listeners_copy;
+
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        listeners_copy.reserve(listeners_.size());
+        for (const auto& [id, listener] : listeners_) {
+            listeners_copy.push_back(listener);
+        }
+    }
+
+    spdlog::debug("EventManager: Emitting {} {} event for {} (workspace: {})",
+                  EventActionToString(event.action),
+                  EntityTypeToString(event.entity_type),
+                  event.entity_id,
+                  event.workspace_id.empty() ? "system" : event.workspace_id);
+
+    for (const auto& listener : listeners_copy) {
+        try {
+            listener(event);
+        } catch (const std::exception& e) {
+            spdlog::error("EventManager: Listener threw exception: {}", e.what());
+        }
+    }
+}
+
+void EventManager::EmitWorkspaceEvent(EventAction action, const std::string& workspace_id,
+                                       const nlohmann::json& data, const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::Workspace,
+        .action = action,
+        .entity_id = workspace_id,
+        .workspace_id = workspace_id,
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitPageEvent(EventAction action, const std::string& page_id,
+                                  const std::string& workspace_id, const nlohmann::json& data,
+                                  const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::Page,
+        .action = action,
+        .entity_id = page_id,
+        .workspace_id = workspace_id,
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitViewEvent(EventAction action, const std::string& view_id,
+                                  const std::string& workspace_id, const nlohmann::json& data,
+                                  const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::View,
+        .action = action,
+        .entity_id = view_id,
+        .workspace_id = workspace_id,
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitCollectionEvent(EventAction action, const std::string& collection_name,
+                                        const std::string& workspace_id, const nlohmann::json& data,
+                                        const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::Collection,
+        .action = action,
+        .entity_id = collection_name,
+        .workspace_id = workspace_id,
+        .collection_name = collection_name,
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitDocumentEvent(EventAction action, const std::string& document_id,
+                                      const std::string& workspace_id, const std::string& collection_name,
+                                      const nlohmann::json& data, const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::Document,
+        .action = action,
+        .entity_id = document_id,
+        .workspace_id = workspace_id,
+        .collection_name = collection_name,
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitSessionEvent(EventAction action, const std::string& session_id,
+                                     const std::string& workspace_id, const nlohmann::json& data,
+                                     const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::Session,
+        .action = action,
+        .entity_id = session_id,
+        .workspace_id = workspace_id,
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitUserEvent(EventAction action, const std::string& user_id,
+                                  const nlohmann::json& data, const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::User,
+        .action = action,
+        .entity_id = user_id,
+        .workspace_id = "",
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitGroupEvent(EventAction action, const std::string& group_id,
+                                   const std::string& workspace_id, const nlohmann::json& data,
+                                   const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::Group,
+        .action = action,
+        .entity_id = group_id,
+        .workspace_id = workspace_id,
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+void EventManager::EmitMembershipEvent(EventAction action, const std::string& membership_id,
+                                        const std::string& workspace_id, const nlohmann::json& data,
+                                        const std::string& actor_id) {
+    Emit({
+        .entity_type = EntityType::Membership,
+        .action = action,
+        .entity_id = membership_id,
+        .workspace_id = workspace_id,
+        .collection_name = "",
+        .data = data,
+        .actor_id = actor_id
+    });
+}
+
+}  // namespace smartbotic::webserver

+ 567 - 2
webserver/src/http_server.cpp

@@ -179,6 +179,16 @@ void WebSocketServer::SendToSubscribers(const std::string& subscription_key, con
     }
     }
 }
 }
 
 
+void WebSocketServer::SendToAuthenticated(const std::string& message) {
+    std::lock_guard<std::mutex> lock(connectionsMutex_);
+    for (auto& [wsi, conn] : connections_) {
+        // Send to all authenticated connections
+        if (conn->authenticated) {
+            QueueMessage(conn.get(), message);
+        }
+    }
+}
+
 auto WebSocketServer::ConnectionCount() const -> size_t {
 auto WebSocketServer::ConnectionCount() const -> size_t {
     std::lock_guard<std::mutex> lock(connectionsMutex_);
     std::lock_guard<std::mutex> lock(connectionsMutex_);
     return connections_.size();
     return connections_.size();
@@ -447,6 +457,34 @@ void HttpServer::BroadcastDocumentEvent(const std::string& workspace_id,
     wsServer_->SendToSubscribers(subscription_key, event_str);
     wsServer_->SendToSubscribers(subscription_key, event_str);
 }
 }
 
 
+void HttpServer::BroadcastWorkspaceEvent(const std::string& workspace_id,
+                                           WorkspaceAction action,
+                                           const nlohmann::json& data) {
+    if (!wsServer_ || !wsHandler_) {
+        return;
+    }
+
+    // Build the event JSON
+    nlohmann::json event = {
+        {"type", "workspace"},
+        {"action", WorkspaceActionToString(action)},
+        {"workspace_id", workspace_id}
+    };
+
+    // Include data for create/update, exclude for delete
+    if (action != WorkspaceAction::Delete && !data.is_null()) {
+        event["data"] = data;
+    }
+
+    std::string event_str = event.dump();
+
+    spdlog::debug("Broadcasting workspace event: {} {}",
+                  WorkspaceActionToString(action), workspace_id);
+
+    // Send to all authenticated connections
+    wsServer_->SendToAuthenticated(event_str);
+}
+
 void HttpServer::BroadcastChatChunk(const std::string& workspace_id,
 void HttpServer::BroadcastChatChunk(const std::string& workspace_id,
                                       const std::string& session_id,
                                       const std::string& session_id,
                                       const nlohmann::json& chunk) {
                                       const nlohmann::json& chunk) {
@@ -625,14 +663,88 @@ auto HttpServer::InitializeServices() -> bool {
         spdlog::warn("LLM service not available at {} - LLM features will be disabled", config_.llm_address);
         spdlog::warn("LLM service not available at {} - LLM features will be disabled", config_.llm_address);
     }
     }
 
 
-    // Initialize ToolService for LLM tool calling
+    // Initialize ArchiveService (before ToolService since it depends on it)
+    archiveService_ = std::make_unique<ArchiveService>(*dbClient_);
+    if (!archiveService_->Initialize()) {
+        spdlog::error("Failed to initialize ArchiveService");
+        return false;
+    }
+    spdlog::info("ArchiveService initialized successfully");
+
+    // Wire up cascade services for archiving operations
+    archiveService_->SetServices(pageService_.get(), viewService_.get(),
+                                  collectionService_.get(), documentService_.get());
+    workspaceService_->SetCascadeServices(archiveService_.get(), pageService_.get(),
+                                           viewService_.get(), collectionService_.get(),
+                                           documentService_.get());
+    spdlog::info("Cascade services wired up for archive operations");
+
+    // Initialize EventManager for unified event broadcasting
+    eventManager_ = std::make_unique<EventManager>();
+
+    // Subscribe to events and broadcast them via WebSocket
+    eventManager_->AddListener([this](const EntityEvent& event) {
+        // Only broadcast if WebSocket server is available
+        if (!wsServer_) {
+            return;
+        }
+
+        // Build the event JSON
+        nlohmann::json ws_event = {
+            {"type", EntityTypeToString(event.entity_type)},
+            {"action", EventActionToString(event.action)},
+            {"entity_id", event.entity_id},
+            {"workspace_id", event.workspace_id}
+        };
+
+        // Include data for create/update, exclude for delete
+        if (event.action != EventAction::Delete && !event.data.is_null()) {
+            ws_event["data"] = event.data;
+        }
+
+        // Include collection name for documents
+        if (!event.collection_name.empty()) {
+            ws_event["collection"] = event.collection_name;
+        }
+
+        std::string event_str = ws_event.dump();
+
+        spdlog::debug("EventManager: Broadcasting {} {} event for {} (workspace: {})",
+                      EventActionToString(event.action),
+                      EntityTypeToString(event.entity_type),
+                      event.entity_id,
+                      event.workspace_id.empty() ? "system" : event.workspace_id);
+
+        // Broadcast to appropriate WebSocket clients
+        if (event.entity_type == EntityType::Workspace) {
+            // Workspace events go to all authenticated clients
+            wsServer_->SendToAuthenticated(event_str);
+        } else if (!event.workspace_id.empty()) {
+            // Other events go to clients subscribed to the workspace/collection
+            std::string subscription_key = WsHandler::BuildSubscriptionKey(
+                event.workspace_id,
+                event.collection_name.empty() ? EntityTypeToString(event.entity_type) : event.collection_name);
+            wsServer_->SendToSubscribers(subscription_key, event_str);
+        }
+    });
+
+    // Set EventManager on services that emit events
+    workspaceService_->SetEventManager(eventManager_.get());
+    viewService_->SetEventManager(eventManager_.get());
+    pageService_->SetEventManager(eventManager_.get());
+    documentService_->SetEventManager(eventManager_.get());
+    spdlog::info("EventManager initialized and wired up for all services");
+
+    // Initialize ToolService for LLM tool calling (with ArchiveService)
     toolService_ = std::make_unique<ToolService>(
     toolService_ = std::make_unique<ToolService>(
         *authorizationService_,
         *authorizationService_,
         *workspaceService_,
         *workspaceService_,
         *viewService_,
         *viewService_,
         *pageService_,
         *pageService_,
         *documentService_,
         *documentService_,
-        *collectionService_);
+        *collectionService_,
+        *membershipService_,
+        archiveService_.get());
     toolService_->Initialize();
     toolService_->Initialize();
     spdlog::info("ToolService initialized successfully");
     spdlog::info("ToolService initialized successfully");
 
 
@@ -727,6 +839,9 @@ void HttpServer::SetupRoutes() {
     // Setup LLM routes (AI assistant)
     // Setup LLM routes (AI assistant)
     SetupLlmRoutes();
     SetupLlmRoutes();
 
 
+    // Setup archive routes (for viewing/restoring deleted items)
+    SetupArchiveRoutes();
+
     // Setup collection routes (before workspace routes since they're more specific)
     // Setup collection routes (before workspace routes since they're more specific)
     SetupCollectionRoutes();
     SetupCollectionRoutes();
 
 
@@ -1774,6 +1889,9 @@ void HttpServer::HandleCreateWorkspace(const httplib::Request& req, httplib::Res
         res.status = 201;
         res.status = 201;
         res.set_content(workspace_json.dump(), "application/json");
         res.set_content(workspace_json.dump(), "application/json");
 
 
+        // Broadcast workspace created event to all authenticated users
+        BroadcastWorkspaceEvent(result.workspace->id, WorkspaceAction::Create, workspace_json);
+
     } catch (const nlohmann::json::parse_error& e) {
     } catch (const nlohmann::json::parse_error& e) {
         res.status = 400;
         res.status = 400;
         res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
         res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
@@ -1992,6 +2110,9 @@ void HttpServer::HandleUpdateWorkspace(const httplib::Request& req, httplib::Res
 
 
         res.set_content(workspace_json.dump(), "application/json");
         res.set_content(workspace_json.dump(), "application/json");
 
 
+        // Broadcast workspace updated event to all authenticated users
+        BroadcastWorkspaceEvent(result.workspace->id, WorkspaceAction::Update, workspace_json);
+
     } catch (const nlohmann::json::parse_error& e) {
     } catch (const nlohmann::json::parse_error& e) {
         res.status = 400;
         res.status = 400;
         res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
         res.set_content(R"({"error":"Invalid JSON body"})", "application/json");
@@ -2040,6 +2161,10 @@ void HttpServer::HandleDeleteWorkspace(const httplib::Request& req, httplib::Res
 
 
         res.set_content(R"({"message":"Workspace deleted successfully"})", "application/json");
         res.set_content(R"({"message":"Workspace deleted successfully"})", "application/json");
 
 
+        // Broadcast workspace deleted event to all authenticated users
+        nlohmann::json delete_data = {{"id", workspace_id}};
+        BroadcastWorkspaceEvent(workspace_id, WorkspaceAction::Delete, delete_data);
+
     } catch (const std::exception& e) {
     } catch (const std::exception& e) {
         spdlog::error("Delete workspace error: {}", e.what());
         spdlog::error("Delete workspace error: {}", e.what());
         res.status = 500;
         res.status = 500;
@@ -7942,4 +8067,444 @@ void HttpServer::HandleLlmGetStreamingState(const httplib::Request& req, httplib
     }
     }
 }
 }
 
 
+// ============================================================================
+// Archive Routes
+// ============================================================================
+
+void HttpServer::SetupArchiveRoutes() {
+    // GET /api/archives - List archives
+    httpServer_->Get("/api/archives",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleListArchives(req, res);
+        });
+
+    // GET /api/archives/:id - Get archive details
+    httpServer_->Get(R"(/api/archives/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleGetArchive(req, res);
+        });
+
+    // POST /api/archives/:id/restore - Restore from archive
+    httpServer_->Post(R"(/api/archives/([^/]+)/restore$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleRestoreArchive(req, res);
+        });
+
+    // GET /api/archives/:id/export - Export archive as JSON
+    httpServer_->Get(R"(/api/archives/([^/]+)/export$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleExportArchive(req, res);
+        });
+
+    // DELETE /api/archives/:id - Permanently delete archive
+    httpServer_->Delete(R"(/api/archives/([^/]+)$)",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleDeleteArchive(req, res);
+        });
+
+    // GET /api/admin/cleanup/archives - Trigger expired archive cleanup (admin only)
+    httpServer_->Get("/api/admin/cleanup/archives",
+        [this](const httplib::Request& req, httplib::Response& res) {
+            HandleCleanupArchives(req, res);
+        });
+
+    spdlog::debug("Archive routes registered");
+}
+
+void HttpServer::HandleListArchives(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    try {
+        ArchiveListRequest list_req;
+
+        // Parse query parameters
+        if (req.has_param("workspace_id")) {
+            list_req.workspace_id = req.get_param_value("workspace_id");
+        }
+
+        if (req.has_param("entity_type")) {
+            auto type_str = req.get_param_value("entity_type");
+            list_req.entity_type = StringToEntityType(type_str);
+        } else {
+            list_req.include_all_types = true;
+        }
+
+        if (req.has_param("parent_archive_id")) {
+            list_req.parent_archive_id = req.get_param_value("parent_archive_id");
+        }
+
+        if (req.has_param("include_expired")) {
+            list_req.include_expired = req.get_param_value("include_expired") == "true";
+        }
+
+        if (req.has_param("limit")) {
+            list_req.limit = std::stoi(req.get_param_value("limit"));
+        }
+
+        if (req.has_param("offset")) {
+            list_req.offset = std::stoi(req.get_param_value("offset"));
+        }
+
+        // Non-superadmins can only see archives from workspaces they belong to
+        if (!IsSuperadmin(*auth_user) && list_req.workspace_id.empty()) {
+            // Get user's workspace memberships
+            auto memberships = membershipService_->ListUserMemberships(auth_user->user_id);
+            if (!memberships.success || memberships.members.empty()) {
+                // No memberships = no archives to see
+                nlohmann::json response;
+                response["archives"] = nlohmann::json::array();
+                response["total_count"] = 0;
+                res.set_content(response.dump(), "application/json");
+                return;
+            }
+
+            // For now, require workspace_id filter for non-superadmins
+            res.status = 400;
+            res.set_content(R"({"error":"workspace_id parameter required for non-admin users"})", "application/json");
+            return;
+        }
+
+        auto result = archiveService_->ListArchives(list_req);
+        if (!result.success) {
+            res.status = 500;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["archives"] = nlohmann::json::array();
+        for (const auto& archive : result.archives) {
+            nlohmann::json archive_json;
+            archive_json["id"] = archive.id;
+            archive_json["entity_type"] = EntityTypeToString(archive.entity_type);
+            archive_json["entity_id"] = archive.entity_id;
+            archive_json["workspace_id"] = archive.workspace_id;
+            archive_json["parent_archive_id"] = archive.parent_archive_id;
+            archive_json["children_count"] = archive.children_count;
+            archive_json["created_at"] = archive.created_at;
+            archive_json["created_by"] = archive.created_by;
+            archive_json["created_by_name"] = ResolveUserName(archive.created_by);
+            archive_json["expires_at"] = archive.expires_at;
+            archive_json["status"] = ArchiveStatusToString(archive.status);
+            // Include a summary from data if available
+            if (archive.data.contains("name")) {
+                archive_json["name"] = archive.data["name"];
+            } else if (archive.data.contains("title")) {
+                archive_json["name"] = archive.data["title"];
+            }
+            response["archives"].push_back(archive_json);
+        }
+        response["total_count"] = result.total_count;
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("ListArchives error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleGetArchive(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    try {
+        auto archive_id = req.matches[1].str();
+
+        auto result = archiveService_->GetArchive(archive_id);
+        if (!result.success) {
+            res.status = result.error == "Archive not found" ? 404 : 500;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& archive = *result.archive;
+
+        // Check workspace access for non-superadmins
+        if (!IsSuperadmin(*auth_user)) {
+            auto membership = membershipService_->GetMembershipByUser(archive.workspace_id, auth_user->user_id);
+            if (!membership.success || !membership.member) {
+                res.status = 403;
+                res.set_content(R"({"error":"Access denied to this archive"})", "application/json");
+                return;
+            }
+        }
+
+        nlohmann::json response;
+        response["id"] = archive.id;
+        response["entity_type"] = EntityTypeToString(archive.entity_type);
+        response["entity_id"] = archive.entity_id;
+        response["workspace_id"] = archive.workspace_id;
+        response["parent_archive_id"] = archive.parent_archive_id;
+        response["data"] = archive.data;
+        response["children_count"] = archive.children_count;
+        response["created_at"] = archive.created_at;
+        response["created_by"] = archive.created_by;
+        response["created_by_name"] = ResolveUserName(archive.created_by);
+        response["expires_at"] = archive.expires_at;
+        response["status"] = ArchiveStatusToString(archive.status);
+        if (!archive.restored_at.empty()) {
+            response["restored_at"] = archive.restored_at;
+        }
+        if (!archive.exported_at.empty()) {
+            response["exported_at"] = archive.exported_at;
+        }
+
+        res.set_content(response.dump(), "application/json");
+
+    } catch (const std::exception& e) {
+        spdlog::error("GetArchive error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleRestoreArchive(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    try {
+        auto archive_id = req.matches[1].str();
+
+        // Get archive first to check workspace access
+        auto archive_result = archiveService_->GetArchive(archive_id);
+        if (!archive_result.success) {
+            res.status = archive_result.error == "Archive not found" ? 404 : 500;
+            res.set_content(nlohmann::json{{"error", archive_result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& archive = *archive_result.archive;
+
+        // Check workspace admin permission for restore (or superadmin)
+        if (!IsSuperadmin(*auth_user)) {
+            // Check if user has workspace_admin permission
+            if (!authorizationService_->HasPermission(*auth_user, permissions::kWorkspaceAdmin)) {
+                res.status = 403;
+                res.set_content(R"({"error":"Requires workspace admin permission to restore archives"})", "application/json");
+                return;
+            }
+            // Also verify user has access to this workspace
+            auto membership = membershipService_->GetMembershipByUser(archive.workspace_id, auth_user->user_id);
+            if (!membership.success || !membership.member) {
+                res.status = 403;
+                res.set_content(R"({"error":"Access denied to this archive"})", "application/json");
+                return;
+            }
+        }
+
+        // Parse request body for options
+        bool restore_children = true;
+        if (!req.body.empty()) {
+            auto body = nlohmann::json::parse(req.body);
+            if (body.contains("restore_children")) {
+                restore_children = body["restore_children"].get<bool>();
+            }
+        }
+
+        auto result = archiveService_->RestoreArchive(archive_id, auth_user->user_id, restore_children);
+        if (!result.success) {
+            res.status = 500;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["success"] = true;
+        response["restored_entity_id"] = result.restored_entity_id;
+        response["restored_children_count"] = result.restored_children_count;
+        response["message"] = "Archive restored successfully";
+
+        res.set_content(response.dump(), "application/json");
+
+        spdlog::info("Archive {} restored by user {}", archive_id, auth_user->user_id);
+
+    } catch (const nlohmann::json::exception& e) {
+        res.status = 400;
+        res.set_content(nlohmann::json{{"error", "Invalid JSON"}}.dump(), "application/json");
+    } catch (const std::exception& e) {
+        spdlog::error("RestoreArchive error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleExportArchive(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    try {
+        auto archive_id = req.matches[1].str();
+
+        // Get archive first to check workspace access
+        auto archive_result = archiveService_->GetArchive(archive_id);
+        if (!archive_result.success) {
+            res.status = archive_result.error == "Archive not found" ? 404 : 500;
+            res.set_content(nlohmann::json{{"error", archive_result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& archive = *archive_result.archive;
+
+        // Check workspace access for non-superadmins
+        if (!IsSuperadmin(*auth_user)) {
+            auto membership = membershipService_->GetMembershipByUser(archive.workspace_id, auth_user->user_id);
+            if (!membership.success || !membership.member) {
+                res.status = 403;
+                res.set_content(R"({"error":"Access denied to this archive"})", "application/json");
+                return;
+            }
+        }
+
+        auto result = archiveService_->ExportArchive(archive_id);
+        if (!result.success) {
+            res.status = 500;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        // Set Content-Disposition header for download
+        std::string filename = "archive_" + archive_id + ".json";
+        res.set_header("Content-Disposition", "attachment; filename=\"" + filename + "\"");
+        res.set_content(result.export_data.dump(2), "application/json");
+
+        spdlog::info("Archive {} exported by user {}", archive_id, auth_user->user_id);
+
+    } catch (const std::exception& e) {
+        spdlog::error("ExportArchive error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleDeleteArchive(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    try {
+        auto archive_id = req.matches[1].str();
+
+        // Get archive first to check workspace access
+        auto archive_result = archiveService_->GetArchive(archive_id);
+        if (!archive_result.success) {
+            res.status = archive_result.error == "Archive not found" ? 404 : 500;
+            res.set_content(nlohmann::json{{"error", archive_result.error}}.dump(), "application/json");
+            return;
+        }
+
+        const auto& archive = *archive_result.archive;
+
+        // Check workspace admin permission for permanent delete (or superadmin)
+        if (!IsSuperadmin(*auth_user)) {
+            // Check if user has workspace_admin permission
+            if (!authorizationService_->HasPermission(*auth_user, permissions::kWorkspaceAdmin)) {
+                res.status = 403;
+                res.set_content(R"({"error":"Requires workspace admin permission to delete archives"})", "application/json");
+                return;
+            }
+            // Also verify user has access to this workspace
+            auto membership = membershipService_->GetMembershipByUser(archive.workspace_id, auth_user->user_id);
+            if (!membership.success || !membership.member) {
+                res.status = 403;
+                res.set_content(R"({"error":"Access denied to this archive"})", "application/json");
+                return;
+            }
+        }
+
+        // Parse request body for options
+        bool delete_children = true;
+        if (!req.body.empty()) {
+            auto body = nlohmann::json::parse(req.body);
+            if (body.contains("delete_children")) {
+                delete_children = body["delete_children"].get<bool>();
+            }
+        }
+
+        auto result = archiveService_->DeleteArchive(archive_id, delete_children);
+        if (!result.success) {
+            res.status = 500;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["success"] = true;
+        response["message"] = "Archive permanently deleted";
+
+        res.set_content(response.dump(), "application/json");
+
+        spdlog::info("Archive {} permanently deleted by user {}", archive_id, auth_user->user_id);
+
+    } catch (const nlohmann::json::exception& e) {
+        res.status = 400;
+        res.set_content(nlohmann::json{{"error", "Invalid JSON"}}.dump(), "application/json");
+    } catch (const std::exception& e) {
+        spdlog::error("DeleteArchive error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
+void HttpServer::HandleCleanupArchives(const httplib::Request& req, httplib::Response& res) {
+    auto auth_user = AuthenticateRequest(req);
+    if (!auth_user) {
+        res.status = 401;
+        res.set_content(R"({"error":"Unauthorized"})", "application/json");
+        return;
+    }
+
+    // Only superadmins can trigger cleanup
+    if (!IsSuperadmin(*auth_user)) {
+        res.status = 403;
+        res.set_content(R"({"error":"Superadmin access required"})", "application/json");
+        return;
+    }
+
+    try {
+        auto result = archiveService_->CleanupExpiredArchives();
+        if (!result.success) {
+            res.status = 500;
+            res.set_content(nlohmann::json{{"error", result.error}}.dump(), "application/json");
+            return;
+        }
+
+        nlohmann::json response;
+        response["success"] = true;
+        response["cleaned_count"] = result.cleaned_count;
+        response["message"] = "Expired archives cleaned up";
+
+        res.set_content(response.dump(), "application/json");
+
+        spdlog::info("Archive cleanup triggered by user {}: {} archives removed",
+                     auth_user->user_id, result.cleaned_count);
+
+    } catch (const std::exception& e) {
+        spdlog::error("CleanupArchives error: {}", e.what());
+        res.status = 500;
+        res.set_content(R"({"error":"Internal server error"})", "application/json");
+    }
+}
+
 }  // namespace smartbotic::webserver
 }  // namespace smartbotic::webserver

+ 85 - 16
webserver/src/page_service.cpp

@@ -8,6 +8,8 @@
 
 
 #include <spdlog/spdlog.h>
 #include <spdlog/spdlog.h>
 
 
+#include "smartbotic/webserver/event_manager.hpp"
+
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
 namespace {
 namespace {
@@ -34,6 +36,10 @@ PageService::PageService(DatabaseClient& db_client) : db_client_(db_client) {}
 
 
 PageService::~PageService() = default;
 PageService::~PageService() = default;
 
 
+void PageService::SetEventManager(EventManager* event_manager) {
+    event_manager_ = event_manager;
+}
+
 PageService::PageService(PageService&&) noexcept = default;
 PageService::PageService(PageService&&) noexcept = default;
 
 
 auto PageService::operator=(PageService&& /*other*/) noexcept -> PageService& {
 auto PageService::operator=(PageService&& /*other*/) noexcept -> PageService& {
@@ -96,6 +102,7 @@ auto PageService::CreatePage(const CreatePageRequest& request) -> PageResult {
     page.settings = request.settings;
     page.settings = request.settings;
     page.created_at = GetCurrentTimestamp();
     page.created_at = GetCurrentTimestamp();
     page.updated_at = page.created_at;
     page.updated_at = page.created_at;
+    page.deleted_at = "";
     page.created_by = request.created_by;
     page.created_by = request.created_by;
     page.shared_with_groups = {};
     page.shared_with_groups = {};
 
 
@@ -131,10 +138,18 @@ auto PageService::CreatePage(const CreatePageRequest& request) -> PageResult {
 
 
     page.id = resp.id();
     page.id = resp.id();
     spdlog::info("Created page {} in workspace {}", page.name, page.workspace_id);
     spdlog::info("Created page {} in workspace {}", page.name, page.workspace_id);
+
+    // Emit page created event
+    if (event_manager_ != nullptr) {
+        event_manager_->EmitPageEvent(EventAction::Create, page.id, page.workspace_id,
+                                       PageToJson(page));
+    }
+
     return {.success = true, .error = "", .page = page};
     return {.success = true, .error = "", .page = page};
 }
 }
 
 
-auto PageService::GetPage(const std::string& workspace_id, const std::string& id) -> PageResult {
+auto PageService::GetPage(const std::string& workspace_id, const std::string& id,
+                          bool include_deleted) -> PageResult {
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
     ::smartbotic::database::GetDocumentRequest req;
     ::smartbotic::database::GetDocumentRequest req;
     ::smartbotic::database::Document resp;
     ::smartbotic::database::Document resp;
@@ -181,11 +196,17 @@ auto PageService::GetPage(const std::string& workspace_id, const std::string& id
         return {.success = false, .error = "Page not found", .page = std::nullopt};
         return {.success = false, .error = "Page not found", .page = std::nullopt};
     }
     }
 
 
+    // Check if deleted
+    if (!include_deleted && page.IsDeleted()) {
+        return {.success = false, .error = "Page not found", .page = std::nullopt};
+    }
+
     return {.success = true, .error = "", .page = page};
     return {.success = true, .error = "", .page = page};
 }
 }
 
 
 auto PageService::GetPageBySlug(const std::string& workspace_id,
 auto PageService::GetPageBySlug(const std::string& workspace_id,
-                                 const std::string& slug) -> PageResult {
+                                 const std::string& slug,
+                                 bool include_deleted) -> PageResult {
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryResponse resp;
     ::smartbotic::database::QueryResponse resp;
@@ -208,6 +229,14 @@ auto PageService::GetPageBySlug(const std::string& workspace_id,
     slug_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
     slug_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
     slug_filter->mutable_value()->set_string_value(slug);
     slug_filter->mutable_value()->set_string_value(slug);
 
 
+    // Filter out deleted pages unless include_deleted is true
+    if (!include_deleted) {
+        auto* deleted_filter = composite->add_filters()->mutable_field();
+        deleted_filter->set_field("deleted_at");
+        deleted_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        deleted_filter->mutable_value()->set_string_value("");
+    }
+
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     if (!status.ok()) {
     if (!status.ok()) {
         return {.success = false, .error = "Failed to query pages: " + status.error_message(), .page = std::nullopt};
         return {.success = false, .error = "Failed to query pages: " + status.error_message(), .page = std::nullopt};
@@ -245,7 +274,8 @@ auto PageService::GetPageBySlug(const std::string& workspace_id,
     return {.success = true, .error = "", .page = page};
     return {.success = true, .error = "", .page = page};
 }
 }
 
 
-auto PageService::ListPages(const std::string& workspace_id) -> PageListResult {
+auto PageService::ListPages(const std::string& workspace_id,
+                            bool include_deleted) -> PageListResult {
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryResponse resp;
     ::smartbotic::database::QueryResponse resp;
@@ -253,11 +283,23 @@ auto PageService::ListPages(const std::string& workspace_id) -> PageListResult {
     req.set_collection(kPagesCollection);
     req.set_collection(kPagesCollection);
     req.set_limit(1000);
     req.set_limit(1000);
 
 
-    // Filter by workspace_id
-    auto* filter = req.mutable_filter()->mutable_field();
-    filter->set_field("workspace_id");
-    filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
-    filter->mutable_value()->set_string_value(workspace_id);
+    // Build composite filter for workspace_id and deleted_at
+    auto* filter = req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value(workspace_id);
+
+    // Filter out deleted pages unless include_deleted is true
+    if (!include_deleted) {
+        auto* deleted_filter = composite->add_filters()->mutable_field();
+        deleted_filter->set_field("deleted_at");
+        deleted_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        deleted_filter->mutable_value()->set_string_value("");
+    }
 
 
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     if (!status.ok()) {
     if (!status.ok()) {
@@ -295,10 +337,11 @@ auto PageService::ListPages(const std::string& workspace_id) -> PageListResult {
     return {.success = true, .error = "", .pages = std::move(pages)};
     return {.success = true, .error = "", .pages = std::move(pages)};
 }
 }
 
 
-auto PageService::ListSidebarPages(const std::string& workspace_id) -> PageListResult {
+auto PageService::ListSidebarPages(const std::string& workspace_id,
+                                   bool include_deleted) -> PageListResult {
     // First list all pages, then filter by show_in_sidebar
     // First list all pages, then filter by show_in_sidebar
     // (We could optimize this with a composite filter, but keeping simple for now)
     // (We could optimize this with a composite filter, but keeping simple for now)
-    auto result = ListPages(workspace_id);
+    auto result = ListPages(workspace_id, include_deleted);
     if (!result.success) {
     if (!result.success) {
         return result;
         return result;
     }
     }
@@ -386,6 +429,13 @@ auto PageService::UpdatePage(const UpdatePageRequest& request) -> PageResult {
     }
     }
 
 
     spdlog::info("Updated page {} in workspace {}", updated.name, updated.workspace_id);
     spdlog::info("Updated page {} in workspace {}", updated.name, updated.workspace_id);
+
+    // Emit page updated event
+    if (event_manager_ != nullptr) {
+        event_manager_->EmitPageEvent(EventAction::Update, updated.id, updated.workspace_id,
+                                       PageToJson(updated));
+    }
+
     return {.success = true, .error = "", .page = updated};
     return {.success = true, .error = "", .page = updated};
 }
 }
 
 
@@ -401,25 +451,42 @@ auto PageService::UpdatePageSharing(const std::string& workspace_id,
 
 
 auto PageService::DeletePage(const std::string& workspace_id, const std::string& id) -> PageResult {
 auto PageService::DeletePage(const std::string& workspace_id, const std::string& id) -> PageResult {
     // Verify page exists and belongs to workspace
     // Verify page exists and belongs to workspace
-    auto existing = GetPage(workspace_id, id);
+    auto existing = GetPage(workspace_id, id, false);
     if (!existing.success || !existing.page) {
     if (!existing.success || !existing.page) {
         return {.success = false, .error = "Page not found", .page = std::nullopt};
         return {.success = false, .error = "Page not found", .page = std::nullopt};
     }
     }
 
 
+    // Soft delete by setting deleted_at
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
-    ::smartbotic::database::DeleteDocumentRequest req;
-    ::smartbotic::database::DeleteDocumentResponse resp;
+    ::smartbotic::database::UpdateDocumentRequest req;
+    ::smartbotic::database::Document resp;
 
 
     req.set_collection(kPagesCollection);
     req.set_collection(kPagesCollection);
     req.set_id(id);
     req.set_id(id);
+    req.set_merge(true);
+
+    auto* data = req.mutable_data();
+    ::smartbotic::database::Value deleted_value;
+    deleted_value.set_string_value(GetCurrentTimestamp());
+    (*data->mutable_fields())["deleted_at"] = deleted_value;
+
+    ::smartbotic::database::Value updated_value;
+    updated_value.set_string_value(GetCurrentTimestamp());
+    (*data->mutable_fields())["updated_at"] = updated_value;
 
 
-    auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
+    auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp);
     if (!status.ok()) {
     if (!status.ok()) {
-        spdlog::error("Failed to delete page {}: {}", id, status.error_message());
+        spdlog::error("Failed to soft delete page {}: {}", id, status.error_message());
         return {.success = false, .error = "Failed to delete page: " + status.error_message(), .page = std::nullopt};
         return {.success = false, .error = "Failed to delete page: " + status.error_message(), .page = std::nullopt};
     }
     }
 
 
-    spdlog::info("Deleted page {} from workspace {}", existing.page->name, workspace_id);
+    spdlog::info("Soft deleted page {} from workspace {}", existing.page->name, workspace_id);
+
+    // Emit page deleted event
+    if (event_manager_ != nullptr) {
+        event_manager_->EmitPageEvent(EventAction::Delete, id, workspace_id, nlohmann::json{});
+    }
+
     return {.success = true, .error = "", .page = std::nullopt};
     return {.success = true, .error = "", .page = std::nullopt};
 }
 }
 
 
@@ -432,6 +499,7 @@ auto PageService::PageToJson(const PageInfo& page) -> nlohmann::json {
         {"settings", SettingsToJson(page.settings)},
         {"settings", SettingsToJson(page.settings)},
         {"created_at", page.created_at},
         {"created_at", page.created_at},
         {"updated_at", page.updated_at},
         {"updated_at", page.updated_at},
+        {"deleted_at", page.deleted_at},
         {"created_by", page.created_by},
         {"created_by", page.created_by},
         {"shared_with_groups", page.shared_with_groups}
         {"shared_with_groups", page.shared_with_groups}
     };
     };
@@ -445,6 +513,7 @@ auto PageService::JsonToPage(const nlohmann::json& json) -> PageInfo {
     page.slug = json.value("slug", "");
     page.slug = json.value("slug", "");
     page.created_at = json.value("created_at", "");
     page.created_at = json.value("created_at", "");
     page.updated_at = json.value("updated_at", "");
     page.updated_at = json.value("updated_at", "");
+    page.deleted_at = json.value("deleted_at", "");
     page.created_by = json.value("created_by", "");
     page.created_by = json.value("created_by", "");
 
 
     if (json.contains("layout") && json["layout"].is_object()) {
     if (json.contains("layout") && json["layout"].is_object()) {

+ 6 - 0
webserver/src/permissions.cpp

@@ -194,6 +194,12 @@ auto GetAllSystemPermissions() -> std::vector<std::string_view> {
 
 
         // LLM Chat access
         // LLM Chat access
         kLlmChat,
         kLlmChat,
+
+        // Archive management
+        kArchivesRead,
+        kArchivesRestore,
+        kArchivesDelete,
+        kArchivesExport,
     };
     };
 }
 }
 
 

File diff ditekan karena terlalu besar
+ 543 - 56
webserver/src/tool_service.cpp


+ 227 - 16
webserver/src/view_service.cpp

@@ -6,6 +6,8 @@
 
 
 #include <spdlog/spdlog.h>
 #include <spdlog/spdlog.h>
 
 
+#include "smartbotic/webserver/event_manager.hpp"
+
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
 auto FieldTypeToString(FieldType type) -> std::string {
 auto FieldTypeToString(FieldType type) -> std::string {
@@ -63,6 +65,10 @@ ViewService::ViewService(DatabaseClient& db_client) : db_client_(db_client) {}
 
 
 ViewService::~ViewService() = default;
 ViewService::~ViewService() = default;
 
 
+void ViewService::SetEventManager(EventManager* event_manager) {
+    event_manager_ = event_manager;
+}
+
 ViewService::ViewService(ViewService&&) noexcept = default;
 ViewService::ViewService(ViewService&&) noexcept = default;
 
 
 auto ViewService::operator=(ViewService&& /*other*/) noexcept -> ViewService& {
 auto ViewService::operator=(ViewService&& /*other*/) noexcept -> ViewService& {
@@ -124,6 +130,7 @@ auto ViewService::CreateView(const CreateViewRequest& request) -> ViewResult {
     view.settings = request.settings;
     view.settings = request.settings;
     view.created_at = GetCurrentTimestamp();
     view.created_at = GetCurrentTimestamp();
     view.updated_at = view.created_at;
     view.updated_at = view.created_at;
+    view.deleted_at = "";
 
 
     auto json_data = ViewToJson(view);
     auto json_data = ViewToJson(view);
 
 
@@ -155,10 +162,18 @@ auto ViewService::CreateView(const CreateViewRequest& request) -> ViewResult {
 
 
     view.id = resp.id();
     view.id = resp.id();
     spdlog::info("Created view {} in workspace {}", view.name, view.workspace_id);
     spdlog::info("Created view {} in workspace {}", view.name, view.workspace_id);
+
+    // Emit view created event
+    if (event_manager_ != nullptr) {
+        event_manager_->EmitViewEvent(EventAction::Create, view.id, view.workspace_id,
+                                       ViewToJson(view));
+    }
+
     return {.success = true, .error = "", .view = view};
     return {.success = true, .error = "", .view = view};
 }
 }
 
 
-auto ViewService::GetView(const std::string& workspace_id, const std::string& id) -> ViewResult {
+auto ViewService::GetView(const std::string& workspace_id, const std::string& id,
+                          bool include_deleted) -> ViewResult {
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
     ::smartbotic::database::GetDocumentRequest req;
     ::smartbotic::database::GetDocumentRequest req;
     ::smartbotic::database::Document resp;
     ::smartbotic::database::Document resp;
@@ -205,11 +220,17 @@ auto ViewService::GetView(const std::string& workspace_id, const std::string& id
         return {.success = false, .error = "View not found", .view = std::nullopt};
         return {.success = false, .error = "View not found", .view = std::nullopt};
     }
     }
 
 
+    // Check if deleted
+    if (!include_deleted && view.IsDeleted()) {
+        return {.success = false, .error = "View not found", .view = std::nullopt};
+    }
+
     return {.success = true, .error = "", .view = view};
     return {.success = true, .error = "", .view = view};
 }
 }
 
 
 auto ViewService::GetViewByName(const std::string& workspace_id,
 auto ViewService::GetViewByName(const std::string& workspace_id,
-                                 const std::string& name) -> ViewResult {
+                                 const std::string& name,
+                                 bool include_deleted) -> ViewResult {
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryResponse resp;
     ::smartbotic::database::QueryResponse resp;
@@ -232,6 +253,14 @@ auto ViewService::GetViewByName(const std::string& workspace_id,
     name_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
     name_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
     name_filter->mutable_value()->set_string_value(name);
     name_filter->mutable_value()->set_string_value(name);
 
 
+    // Filter out deleted views unless include_deleted is true
+    if (!include_deleted) {
+        auto* deleted_filter = composite->add_filters()->mutable_field();
+        deleted_filter->set_field("deleted_at");
+        deleted_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        deleted_filter->mutable_value()->set_string_value("");
+    }
+
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     if (!status.ok()) {
     if (!status.ok()) {
         return {.success = false, .error = "Failed to query views: " + status.error_message(), .view = std::nullopt};
         return {.success = false, .error = "Failed to query views: " + status.error_message(), .view = std::nullopt};
@@ -267,7 +296,8 @@ auto ViewService::GetViewByName(const std::string& workspace_id,
     return {.success = true, .error = "", .view = view};
     return {.success = true, .error = "", .view = view};
 }
 }
 
 
-auto ViewService::ListViews(const std::string& workspace_id) -> ViewListResult {
+auto ViewService::ListViews(const std::string& workspace_id,
+                            bool include_deleted) -> ViewListResult {
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryResponse resp;
     ::smartbotic::database::QueryResponse resp;
@@ -275,11 +305,23 @@ auto ViewService::ListViews(const std::string& workspace_id) -> ViewListResult {
     req.set_collection(kViewsCollection);
     req.set_collection(kViewsCollection);
     req.set_limit(1000);
     req.set_limit(1000);
 
 
-    // Filter by workspace_id
-    auto* filter = req.mutable_filter()->mutable_field();
-    filter->set_field("workspace_id");
-    filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
-    filter->mutable_value()->set_string_value(workspace_id);
+    // Build composite filter for workspace_id and deleted_at
+    auto* filter = req.mutable_filter();
+    auto* composite = filter->mutable_composite();
+    composite->set_operator_(::smartbotic::database::COMPOSITE_OPERATOR_AND);
+
+    auto* ws_filter = composite->add_filters()->mutable_field();
+    ws_filter->set_field("workspace_id");
+    ws_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+    ws_filter->mutable_value()->set_string_value(workspace_id);
+
+    // Filter out deleted views unless include_deleted is true
+    if (!include_deleted) {
+        auto* deleted_filter = composite->add_filters()->mutable_field();
+        deleted_filter->set_field("deleted_at");
+        deleted_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        deleted_filter->mutable_value()->set_string_value("");
+    }
 
 
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     if (!status.ok()) {
     if (!status.ok()) {
@@ -316,7 +358,8 @@ auto ViewService::ListViews(const std::string& workspace_id) -> ViewListResult {
 }
 }
 
 
 auto ViewService::ListViewsForCollection(const std::string& workspace_id,
 auto ViewService::ListViewsForCollection(const std::string& workspace_id,
-                                          const std::string& collection_name) -> ViewListResult {
+                                          const std::string& collection_name,
+                                          bool include_deleted) -> ViewListResult {
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryRequest req;
     ::smartbotic::database::QueryResponse resp;
     ::smartbotic::database::QueryResponse resp;
@@ -339,6 +382,14 @@ auto ViewService::ListViewsForCollection(const std::string& workspace_id,
     coll_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
     coll_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
     coll_filter->mutable_value()->set_string_value(collection_name);
     coll_filter->mutable_value()->set_string_value(collection_name);
 
 
+    // Filter out deleted views unless include_deleted is true
+    if (!include_deleted) {
+        auto* deleted_filter = composite->add_filters()->mutable_field();
+        deleted_filter->set_field("deleted_at");
+        deleted_filter->set_operator_(::smartbotic::database::FILTER_OPERATOR_EQUAL);
+        deleted_filter->mutable_value()->set_string_value("");
+    }
+
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     auto status = db_client_.GetQueryService()->Query(&ctx, req, &resp);
     if (!status.ok()) {
     if (!status.ok()) {
         return {.success = false, .error = "Failed to list views: " + status.error_message(), .views = {}};
         return {.success = false, .error = "Failed to list views: " + status.error_message(), .views = {}};
@@ -435,30 +486,68 @@ auto ViewService::UpdateView(const UpdateViewRequest& request) -> ViewResult {
     }
     }
 
 
     spdlog::info("Updated view {} in workspace {}", updated.name, updated.workspace_id);
     spdlog::info("Updated view {} in workspace {}", updated.name, updated.workspace_id);
+
+    // Emit view updated event
+    if (event_manager_ != nullptr) {
+        event_manager_->EmitViewEvent(EventAction::Update, updated.id, updated.workspace_id,
+                                       ViewToJson(updated));
+    }
+
     return {.success = true, .error = "", .view = updated};
     return {.success = true, .error = "", .view = updated};
 }
 }
 
 
 auto ViewService::DeleteView(const std::string& workspace_id, const std::string& id) -> ViewResult {
 auto ViewService::DeleteView(const std::string& workspace_id, const std::string& id) -> ViewResult {
     // Verify view exists and belongs to workspace
     // Verify view exists and belongs to workspace
-    auto existing = GetView(workspace_id, id);
+    auto existing = GetView(workspace_id, id, false);
     if (!existing.success || !existing.view) {
     if (!existing.success || !existing.view) {
         return {.success = false, .error = "View not found", .view = std::nullopt};
         return {.success = false, .error = "View not found", .view = std::nullopt};
     }
     }
 
 
+    // Get current timestamp for soft delete
+    auto now = std::chrono::system_clock::now();
+    auto time = std::chrono::system_clock::to_time_t(now);
+    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
+        now.time_since_epoch()) % 1000;
+
+    std::tm tm{};
+    gmtime_r(&time, &tm);
+
+    std::ostringstream oss;
+    oss << std::put_time(&tm, "%Y-%m-%dT%H:%M:%S");
+    oss << "." << std::setfill('0') << std::setw(3) << ms.count() << "Z";
+    std::string current_timestamp = oss.str();
+
+    // Soft delete by setting deleted_at
     grpc::ClientContext ctx;
     grpc::ClientContext ctx;
-    ::smartbotic::database::DeleteDocumentRequest req;
-    ::smartbotic::database::DeleteDocumentResponse resp;
+    ::smartbotic::database::UpdateDocumentRequest req;
+    ::smartbotic::database::Document resp;
 
 
     req.set_collection(kViewsCollection);
     req.set_collection(kViewsCollection);
     req.set_id(id);
     req.set_id(id);
+    req.set_merge(true);
+
+    auto* data = req.mutable_data();
+    ::smartbotic::database::Value deleted_value;
+    deleted_value.set_string_value(current_timestamp);
+    (*data->mutable_fields())["deleted_at"] = deleted_value;
 
 
-    auto status = db_client_.GetDocumentService()->DeleteDocument(&ctx, req, &resp);
+    ::smartbotic::database::Value updated_value;
+    updated_value.set_string_value(current_timestamp);
+    (*data->mutable_fields())["updated_at"] = updated_value;
+
+    auto status = db_client_.GetDocumentService()->UpdateDocument(&ctx, req, &resp);
     if (!status.ok()) {
     if (!status.ok()) {
-        spdlog::error("Failed to delete view {}: {}", id, status.error_message());
+        spdlog::error("Failed to soft delete view {}: {}", id, status.error_message());
         return {.success = false, .error = "Failed to delete view: " + status.error_message(), .view = std::nullopt};
         return {.success = false, .error = "Failed to delete view: " + status.error_message(), .view = std::nullopt};
     }
     }
 
 
-    spdlog::info("Deleted view {} from workspace {}", existing.view->name, workspace_id);
+    spdlog::info("Soft deleted view {} from workspace {}", existing.view->name, workspace_id);
+
+    // Emit view deleted event
+    if (event_manager_ != nullptr) {
+        event_manager_->EmitViewEvent(EventAction::Delete, id, workspace_id, nlohmann::json{});
+    }
+
     return {.success = true, .error = "", .view = std::nullopt};
     return {.success = true, .error = "", .view = std::nullopt};
 }
 }
 
 
@@ -470,7 +559,8 @@ auto ViewService::ViewToJson(const ViewInfo& view) -> nlohmann::json {
         {"schema", SchemaToJson(view.schema)},
         {"schema", SchemaToJson(view.schema)},
         {"settings", SettingsToJson(view.settings)},
         {"settings", SettingsToJson(view.settings)},
         {"created_at", view.created_at},
         {"created_at", view.created_at},
-        {"updated_at", view.updated_at}
+        {"updated_at", view.updated_at},
+        {"deleted_at", view.deleted_at}
     };
     };
 }
 }
 
 
@@ -482,6 +572,7 @@ auto ViewService::JsonToView(const nlohmann::json& json) -> ViewInfo {
     view.collection_name = json.value("collection_name", "");
     view.collection_name = json.value("collection_name", "");
     view.created_at = json.value("created_at", "");
     view.created_at = json.value("created_at", "");
     view.updated_at = json.value("updated_at", "");
     view.updated_at = json.value("updated_at", "");
+    view.deleted_at = json.value("deleted_at", "");
 
 
     if (json.contains("schema") && json["schema"].is_object()) {
     if (json.contains("schema") && json["schema"].is_object()) {
         view.schema = JsonToSchema(json["schema"]);
         view.schema = JsonToSchema(json["schema"]);
@@ -606,4 +697,124 @@ auto ViewService::JsonToSettings(const nlohmann::json& json) -> ViewSettings {
     return settings;
     return settings;
 }
 }
 
 
+auto ViewService::ValidateDataAgainstSchema(
+    const nlohmann::json& data,
+    const ViewSchema& schema) -> SchemaValidationResult {
+    SchemaValidationResult result;
+
+    // Build a map of field name to SchemaField for quick lookup
+    std::map<std::string, const SchemaField*> field_map;
+    for (const auto& field : schema.fields) {
+        field_map[field.name] = &field;
+    }
+
+    // Validate each field in the data
+    for (auto it = data.begin(); it != data.end(); ++it) {
+        const std::string& field_name = it.key();
+        const auto& value = it.value();
+
+        // Skip internal fields
+        if (field_name.starts_with("_")) {
+            continue;
+        }
+
+        auto field_it = field_map.find(field_name);
+        if (field_it == field_map.end()) {
+            // Unknown field - we allow this for flexibility
+            continue;
+        }
+
+        const SchemaField* field = field_it->second;
+
+        // Validate select fields
+        if (field->type == FieldType::Select) {
+            if (!value.is_null() && value.is_string()) {
+                std::string str_value = value.get<std::string>();
+
+                // Get valid options from field.options
+                std::vector<std::string> valid_options;
+                bool value_is_valid = false;
+
+                if (field->options.is_array()) {
+                    for (const auto& opt : field->options) {
+                        std::string opt_value;
+                        if (opt.is_string()) {
+                            opt_value = opt.get<std::string>();
+                        } else if (opt.is_object() && opt.contains("value")) {
+                            if (opt["value"].is_string()) {
+                                opt_value = opt["value"].get<std::string>();
+                            }
+                        }
+                        if (!opt_value.empty()) {
+                            valid_options.push_back(opt_value);
+                            if (opt_value == str_value) {
+                                value_is_valid = true;
+                            }
+                        }
+                    }
+                }
+
+                if (!value_is_valid && !valid_options.empty()) {
+                    result.valid = false;
+
+                    // Format the error message with valid options
+                    std::string options_str;
+                    for (size_t i = 0; i < valid_options.size(); ++i) {
+                        if (i > 0) {
+                            options_str += ", ";
+                        }
+                        options_str += "'" + valid_options[i] + "'";
+                    }
+
+                    result.errors.push_back(
+                        "Invalid value '" + str_value + "' for field '" + field_name +
+                        "'. Valid options are: " + options_str);
+                    result.field_suggestions[field_name] = valid_options;
+                }
+            }
+        }
+
+        // Validate required fields (only check if value is null/empty)
+        if (field->required) {
+            bool is_empty = value.is_null();
+            if (value.is_string() && value.get<std::string>().empty()) {
+                is_empty = true;
+            }
+            if (is_empty) {
+                result.valid = false;
+                result.errors.push_back("Field '" + field_name + "' is required");
+            }
+        }
+    }
+
+    // Check for missing required fields
+    for (const auto& field : schema.fields) {
+        if (field.required && !data.contains(field.name)) {
+            // Only report missing required field if the data is meant to be complete
+            // For partial updates, we don't require all fields
+            // This check is intentionally lenient - specific tools may want stricter behavior
+        }
+    }
+
+    return result;
+}
+
+auto ViewService::GetDefaultViewForCollection(const std::string& workspace_id,
+                                               const std::string& collection_name) -> ViewResult {
+    auto views_result = ListViewsForCollection(workspace_id, collection_name);
+    if (!views_result.success || views_result.views.empty()) {
+        return {.success = false, .error = "No views found for collection", .view = std::nullopt};
+    }
+
+    // Find default view
+    for (const auto& view : views_result.views) {
+        if (view.settings.is_default) {
+            return {.success = true, .error = "", .view = view};
+        }
+    }
+
+    // Return first view if no default
+    return {.success = true, .error = "", .view = views_result.views[0]};
+}
+
 }  // namespace smartbotic::webserver
 }  // namespace smartbotic::webserver

+ 177 - 3
webserver/src/workspace_service.cpp

@@ -6,6 +6,13 @@
 #include <iomanip>
 #include <iomanip>
 #include <sstream>
 #include <sstream>
 
 
+#include "smartbotic/webserver/archive_service.hpp"
+#include "smartbotic/webserver/event_manager.hpp"
+#include "smartbotic/webserver/collection_service.hpp"
+#include "smartbotic/webserver/document_service.hpp"
+#include "smartbotic/webserver/page_service.hpp"
+#include "smartbotic/webserver/view_service.hpp"
+
 namespace smartbotic::webserver {
 namespace smartbotic::webserver {
 
 
 namespace {
 namespace {
@@ -131,8 +138,22 @@ auto WorkspaceService::CreateWorkspace(const CreateWorkspaceRequest& request) ->
         return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
         return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
     }
     }
 
 
+    auto workspace = DocumentToWorkspace(created_doc);
     spdlog::info("Created workspace {} with name {}", created_doc.id(), request.name);
     spdlog::info("Created workspace {} with name {}", created_doc.id(), request.name);
-    return WorkspaceResult{.success = true, .error = "", .workspace = DocumentToWorkspace(created_doc)};
+
+    // Emit workspace created event
+    if (event_manager_ != nullptr) {
+        nlohmann::json workspace_json = {
+            {"id", workspace.id},
+            {"name", workspace.name},
+            {"settings", workspace.settings},
+            {"created_at", workspace.created_at},
+            {"updated_at", workspace.updated_at}
+        };
+        event_manager_->EmitWorkspaceEvent(EventAction::Create, workspace.id, workspace_json, request.actor_id);
+    }
+
+    return WorkspaceResult{.success = true, .error = "", .workspace = workspace};
 }
 }
 
 
 auto WorkspaceService::GetWorkspace(const std::string& id, bool include_deleted) -> WorkspaceResult {
 auto WorkspaceService::GetWorkspace(const std::string& id, bool include_deleted) -> WorkspaceResult {
@@ -330,8 +351,22 @@ auto WorkspaceService::UpdateWorkspace(const std::string& id, const UpdateWorksp
         return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
         return WorkspaceResult{.success = false, .error = status.error_message(), .workspace = std::nullopt};
     }
     }
 
 
+    auto workspace = DocumentToWorkspace(updated_doc);
     spdlog::info("Updated workspace {}", id);
     spdlog::info("Updated workspace {}", id);
-    return WorkspaceResult{.success = true, .error = "", .workspace = DocumentToWorkspace(updated_doc)};
+
+    // Emit workspace updated event
+    if (event_manager_ != nullptr) {
+        nlohmann::json workspace_json = {
+            {"id", workspace.id},
+            {"name", workspace.name},
+            {"settings", workspace.settings},
+            {"created_at", workspace.created_at},
+            {"updated_at", workspace.updated_at}
+        };
+        event_manager_->EmitWorkspaceEvent(EventAction::Update, workspace.id, workspace_json, request.actor_id);
+    }
+
+    return WorkspaceResult{.success = true, .error = "", .workspace = workspace};
 }
 }
 
 
 auto WorkspaceService::DeleteWorkspace(const std::string& id) -> WorkspaceResult {
 auto WorkspaceService::DeleteWorkspace(const std::string& id) -> WorkspaceResult {
@@ -369,11 +404,19 @@ auto WorkspaceService::DeleteWorkspace(const std::string& id) -> WorkspaceResult
     }
     }
 
 
     spdlog::info("Soft deleted workspace {}", id);
     spdlog::info("Soft deleted workspace {}", id);
+
+    // Emit workspace deleted event
+    if (event_manager_ != nullptr) {
+        nlohmann::json workspace_json = {{"id", id}};
+        event_manager_->EmitWorkspaceEvent(EventAction::Delete, id, workspace_json);
+    }
+
     return WorkspaceResult{.success = true, .error = "", .workspace = DocumentToWorkspace(updated_doc)};
     return WorkspaceResult{.success = true, .error = "", .workspace = DocumentToWorkspace(updated_doc)};
 }
 }
 
 
 auto WorkspaceService::NameExists(const std::string& name) -> bool {
 auto WorkspaceService::NameExists(const std::string& name) -> bool {
-    auto result = GetWorkspaceByName(name, true);
+    // Don't include deleted workspaces - allow reusing names of deleted workspaces
+    auto result = GetWorkspaceByName(name, false);
     return result.success;
     return result.success;
 }
 }
 
 
@@ -413,4 +456,135 @@ auto WorkspaceService::GetCurrentTimestamp() -> std::string {
     return oss.str();
     return oss.str();
 }
 }
 
 
+void WorkspaceService::SetCascadeServices(ArchiveService* archive_service,
+                                          PageService* page_service,
+                                          ViewService* view_service,
+                                          CollectionService* collection_service,
+                                          DocumentService* document_service) {
+    archive_service_ = archive_service;
+    page_service_ = page_service;
+    view_service_ = view_service;
+    collection_service_ = collection_service;
+    document_service_ = document_service;
+}
+
+void WorkspaceService::SetEventManager(EventManager* event_manager) {
+    event_manager_ = event_manager;
+}
+
+auto WorkspaceService::DeleteWorkspaceWithArchive(const std::string& id,
+                                                   const DeleteWorkspaceOptions& options) -> DeleteWorkspaceResult {
+    if (!initialized_) {
+        return DeleteWorkspaceResult{.success = false, .error = "Workspace service not initialized",
+                                     .archive_id = "", .cascaded_count = 0};
+    }
+
+    // Get the existing workspace
+    auto existing = GetWorkspace(id, false);
+    if (!existing.success || !existing.workspace) {
+        return DeleteWorkspaceResult{.success = false, .error = "Workspace not found",
+                                     .archive_id = "", .cascaded_count = 0};
+    }
+
+    DeleteWorkspaceResult result;
+    result.success = true;
+    result.cascaded_count = 0;
+
+    // Create archive if requested
+    if (options.create_archive && archive_service_ != nullptr) {
+        // Build workspace data for archive
+        nlohmann::json workspace_data;
+        workspace_data["id"] = existing.workspace->id;
+        workspace_data["name"] = existing.workspace->name;
+        workspace_data["settings"] = existing.workspace->settings;
+        workspace_data["created_at"] = existing.workspace->created_at;
+        workspace_data["updated_at"] = existing.workspace->updated_at;
+        workspace_data["created_by"] = existing.workspace->created_by;
+        workspace_data["updated_by"] = existing.workspace->updated_by;
+
+        auto archive_result = archive_service_->ArchiveWorkspace(id, workspace_data,
+                                                                  options.actor_id, options.retention_days);
+        if (!archive_result.success) {
+            spdlog::warn("Failed to archive workspace {}: {}", id, archive_result.error);
+            // Continue with delete even if archiving fails
+        } else if (archive_result.archive) {
+            result.archive_id = archive_result.archive->id;
+            result.cascaded_count = static_cast<int>(archive_result.archive->children_count);
+            spdlog::info("Archived workspace {} with archive ID {}", id, result.archive_id);
+        }
+    }
+
+    // Cascade delete child entities if requested
+    if (options.cascade_delete) {
+        // Delete all pages in this workspace
+        if (page_service_ != nullptr) {
+            auto pages = page_service_->ListPages(id, false);
+            if (pages.success) {
+                for (const auto& page : pages.pages) {
+                    auto page_result = page_service_->DeletePage(id, page.id);
+                    if (page_result.success) {
+                        result.cascaded_count++;
+                    }
+                }
+            }
+        }
+
+        // Delete all views in this workspace
+        if (view_service_ != nullptr) {
+            auto views = view_service_->ListViews(id, false);
+            if (views.success) {
+                for (const auto& view : views.views) {
+                    auto view_result = view_service_->DeleteView(id, view.id);
+                    if (view_result.success) {
+                        result.cascaded_count++;
+                    }
+                }
+            }
+        }
+
+        // Delete all collections in this workspace (and their documents)
+        if (collection_service_ != nullptr) {
+            auto collections = collection_service_->ListCollections(id);
+            if (collections.success) {
+                for (const auto& coll : collections.collections) {
+                    // Delete documents in this collection
+                    if (document_service_ != nullptr) {
+                        DocumentQuery doc_query;
+                        doc_query.workspace_id = id;
+                        doc_query.collection = coll.name;
+                        doc_query.limit = 10000;
+
+                        auto docs = document_service_->ListDocuments(doc_query);
+                        if (docs.success) {
+                            for (const auto& doc : docs.documents) {
+                                auto doc_result = document_service_->DeleteDocument(id, coll.name, doc.id);
+                                if (doc_result.success) {
+                                    result.cascaded_count++;
+                                }
+                            }
+                        }
+                    }
+
+                    // Drop the collection (force delete even if not empty)
+                    auto coll_result = collection_service_->DropCollection(id, coll.name, true);
+                    if (coll_result.success) {
+                        result.cascaded_count++;
+                    }
+                }
+            }
+        }
+    }
+
+    // Soft delete the workspace itself
+    auto delete_result = DeleteWorkspace(id);
+    if (!delete_result.success) {
+        result.success = false;
+        result.error = delete_result.error;
+        return result;
+    }
+
+    spdlog::info("Deleted workspace {} with {} cascaded entities", id, result.cascaded_count);
+    return result;
+}
+
 }  // namespace smartbotic::webserver
 }  // namespace smartbotic::webserver

+ 35 - 0
webserver/src/ws_handler.cpp

@@ -13,6 +13,7 @@ auto ParseMessageType(const std::string& type) -> WsMessageType {
     if (type == "subscribe_chat") return WsMessageType::SubscribeChat;
     if (type == "subscribe_chat") return WsMessageType::SubscribeChat;
     if (type == "unsubscribe_chat") return WsMessageType::UnsubscribeChat;
     if (type == "unsubscribe_chat") return WsMessageType::UnsubscribeChat;
     if (type == "document") return WsMessageType::Document;
     if (type == "document") return WsMessageType::Document;
+    if (type == "workspace") return WsMessageType::Workspace;
     if (type == "chat_chunk") return WsMessageType::ChatChunk;
     if (type == "chat_chunk") return WsMessageType::ChatChunk;
     if (type == "chat_state") return WsMessageType::ChatState;
     if (type == "chat_state") return WsMessageType::ChatState;
     if (type == "notification") return WsMessageType::Notification;
     if (type == "notification") return WsMessageType::Notification;
@@ -29,6 +30,7 @@ auto MessageTypeToString(WsMessageType type) -> std::string {
         case WsMessageType::SubscribeChat: return "subscribe_chat";
         case WsMessageType::SubscribeChat: return "subscribe_chat";
         case WsMessageType::UnsubscribeChat: return "unsubscribe_chat";
         case WsMessageType::UnsubscribeChat: return "unsubscribe_chat";
         case WsMessageType::Document: return "document";
         case WsMessageType::Document: return "document";
+        case WsMessageType::Workspace: return "workspace";
         case WsMessageType::ChatChunk: return "chat_chunk";
         case WsMessageType::ChatChunk: return "chat_chunk";
         case WsMessageType::ChatState: return "chat_state";
         case WsMessageType::ChatState: return "chat_state";
         case WsMessageType::Notification: return "notification";
         case WsMessageType::Notification: return "notification";
@@ -47,6 +49,15 @@ auto DocumentActionToString(DocumentAction action) -> std::string {
     return "unknown";
     return "unknown";
 }
 }
 
 
+auto WorkspaceActionToString(WorkspaceAction action) -> std::string {
+    switch (action) {
+        case WorkspaceAction::Create: return "create";
+        case WorkspaceAction::Update: return "update";
+        case WorkspaceAction::Delete: return "delete";
+    }
+    return "unknown";
+}
+
 auto NotificationLevelToString(NotificationLevel level) -> std::string {
 auto NotificationLevelToString(NotificationLevel level) -> std::string {
     switch (level) {
     switch (level) {
         case NotificationLevel::Info: return "info";
         case NotificationLevel::Info: return "info";
@@ -274,6 +285,30 @@ void WsHandler::BroadcastDocumentEvent(const std::string& workspace_id,
     broadcast_to_subscribed(event_str);
     broadcast_to_subscribed(event_str);
 }
 }
 
 
+void WsHandler::BroadcastWorkspaceEvent(const std::string& workspace_id,
+                                          WorkspaceAction action,
+                                          const nlohmann::json& data,
+                                          const std::function<void(const std::string&)>& broadcast_callback) {
+    nlohmann::json event = {
+        {"type", "workspace"},
+        {"action", WorkspaceActionToString(action)},
+        {"workspace_id", workspace_id}
+    };
+
+    // Include data for create/update, exclude for delete
+    if (action != WorkspaceAction::Delete && !data.is_null()) {
+        event["data"] = data;
+    }
+
+    std::string event_str = event.dump();
+
+    spdlog::debug("Broadcasting workspace {} event for workspace {}",
+                  WorkspaceActionToString(action), workspace_id);
+
+    // The broadcast_callback handles iterating over authenticated connections
+    broadcast_callback(event_str);
+}
+
 void WsHandler::SendNotification(WsConnection* conn, const std::string& message,
 void WsHandler::SendNotification(WsConnection* conn, const std::string& message,
                                    NotificationLevel level, const SendCallback& send_callback) {
                                    NotificationLevel level, const SendCallback& send_callback) {
     nlohmann::json notification = {
     nlohmann::json notification = {

+ 2 - 0
webui/src/App.tsx

@@ -16,6 +16,7 @@ import Workspaces from '@/pages/Workspaces'
 import WorkspaceEdit from '@/pages/WorkspaceEdit'
 import WorkspaceEdit from '@/pages/WorkspaceEdit'
 import LlmProviders from '@/pages/LlmProviders'
 import LlmProviders from '@/pages/LlmProviders'
 import ChatPage from '@/pages/ChatPage'
 import ChatPage from '@/pages/ChatPage'
+import Archives from '@/pages/Archives'
 import ProtectedRoute from '@/components/ProtectedRoute'
 import ProtectedRoute from '@/components/ProtectedRoute'
 import { DashboardLayout } from '@/components/layout'
 import { DashboardLayout } from '@/components/layout'
 
 
@@ -109,6 +110,7 @@ function App() {
         <Route path="/workspaces/:workspaceId/edit" element={<WorkspaceEdit />} />
         <Route path="/workspaces/:workspaceId/edit" element={<WorkspaceEdit />} />
         <Route path="/llm-providers" element={<LlmProviders />} />
         <Route path="/llm-providers" element={<LlmProviders />} />
         <Route path="/chat" element={<ChatPage />} />
         <Route path="/chat" element={<ChatPage />} />
+        <Route path="/archives" element={<Archives />} />
       </Route>
       </Route>
 
 
       {/* Catch-all redirect */}
       {/* Catch-all redirect */}

+ 19 - 19
webui/src/components/Chat/ChatMessage.tsx

@@ -97,25 +97,6 @@ export function ChatMessage({
           </div>
           </div>
         )}
         )}
 
 
-        {/* Tool calls - shown first for assistant messages */}
-        {!isUser && hasToolCalls && (
-          <div className="w-full max-w-md mb-2">
-            {message.tool_calls!.map((toolCall) => {
-              const toolState = toolStates.find((t) => t.tool_call.id === toolCall.id) || {
-                tool_call: toolCall,
-                status: 'completed' as const,
-              }
-              return (
-                <ToolCallCard
-                  key={toolCall.id}
-                  toolState={toolState}
-                  onExecute={onExecuteTool ? () => onExecuteTool(toolCall.id) : undefined}
-                />
-              )
-            })}
-          </div>
-        )}
-
         {/* Content bubble - only show if there's actual content */}
         {/* Content bubble - only show if there's actual content */}
         {(isUser || hasContent || isStreaming) && (
         {(isUser || hasContent || isStreaming) && (
           <div
           <div
@@ -178,6 +159,25 @@ export function ChatMessage({
           </div>
           </div>
         )}
         )}
 
 
+        {/* Tool calls - shown after content for assistant messages */}
+        {!isUser && hasToolCalls && (
+          <div className="w-full max-w-md mt-2">
+            {message.tool_calls!.map((toolCall) => {
+              const toolState = toolStates.find((t) => t.tool_call.id === toolCall.id) || {
+                tool_call: toolCall,
+                status: 'completed' as const,
+              }
+              return (
+                <ToolCallCard
+                  key={toolCall.id}
+                  toolState={toolState}
+                  onExecute={onExecuteTool ? () => onExecuteTool(toolCall.id) : undefined}
+                />
+              )
+            })}
+          </div>
+        )}
+
         {/* Timestamp and context info */}
         {/* Timestamp and context info */}
         <div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
         <div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
           <span>{formattedTime}</span>
           <span>{formattedTime}</span>

+ 45 - 13
webui/src/components/PageRenderer/DocumentListRenderer.tsx

@@ -21,6 +21,12 @@ interface ViewField {
   options?: Array<{ value: string; label: string }> | string[]
   options?: Array<{ value: string; label: string }> | string[]
   widget?: string  // Widget type override (e.g., 'textarea' for text fields)
   widget?: string  // Widget type override (e.g., 'textarea' for text fields)
   markdown?: boolean  // Enable markdown editing/rendering for textarea fields
   markdown?: boolean  // Enable markdown editing/rendering for textarea fields
+  // List view display options
+  hidden?: boolean  // Hide field from list/table views (still visible in edit/create forms)
+  maxLength?: number  // Maximum characters to display in list views (truncate with ellipsis)
+  // Detail view display options
+  hidden_in_detail?: boolean  // Hide field from single item detail view
+  detail_widget?: string  // Widget to use in detail view ('readonly', 'badge', 'link', 'markdown', 'html', 'swatch')
 }
 }
 
 
 interface View {
 interface View {
@@ -158,9 +164,14 @@ export function DocumentListRenderer({
     },
     },
   })
   })
 
 
-  // Get visible columns
-  const visibleColumns = config.visibleColumns || view?.schema?.fields?.map((f) => f.name) || []
+  // Get visible columns (filter out hidden fields)
   const fields = view?.schema?.fields || []
   const fields = view?.schema?.fields || []
+  const baseVisibleColumns = config.visibleColumns || fields.map((f) => f.name) || []
+  // Filter out fields that are marked as hidden in the schema
+  const visibleColumns = baseVisibleColumns.filter(colName => {
+    const field = fields.find(f => f.name === colName)
+    return !field?.hidden
+  })
   const collectionName = view?.collection_name || ''
   const collectionName = view?.collection_name || ''
 
 
   // Get quick create/edit fields (excludes system fields which are read-only)
   // Get quick create/edit fields (excludes system fields which are read-only)
@@ -650,9 +661,15 @@ export function DocumentListRenderer({
                               </td>
                               </td>
                             )
                             )
                           }
                           }
+                          const formattedValue = formatCellValue(value, field?.type, field?.maxLength)
+                          const isTruncated = field?.maxLength && String(value || '').length > field.maxLength
                           return (
                           return (
-                            <td key={colName} className="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
-                              {formatCellValue(value, field?.type)}
+                            <td
+                              key={colName}
+                              className={`px-4 py-3 text-sm text-gray-900 ${field?.maxLength ? 'max-w-xs' : 'whitespace-nowrap'}`}
+                              title={isTruncated ? String(value) : undefined}
+                            >
+                              {formattedValue}
                             </td>
                             </td>
                           )
                           )
                         })}
                         })}
@@ -757,7 +774,7 @@ export function DocumentListRenderer({
                   >
                   >
                     {/* Title */}
                     {/* Title */}
                     <h4 className="font-medium text-gray-900 truncate">
                     <h4 className="font-medium text-gray-900 truncate">
-                      {formatCellValue(getColumnValue(doc, titleField), field?.type)}
+                      {formatCellValue(getColumnValue(doc, titleField), field?.type, field?.maxLength)}
                     </h4>
                     </h4>
 
 
                     {/* Body fields */}
                     {/* Body fields */}
@@ -781,8 +798,8 @@ export function DocumentListRenderer({
                           return (
                           return (
                             <div key={colName} className="flex items-center text-sm">
                             <div key={colName} className="flex items-center text-sm">
                               <span className="text-gray-500 mr-2">{bodyField?.label || colName}:</span>
                               <span className="text-gray-500 mr-2">{bodyField?.label || colName}:</span>
-                              <span className="text-gray-900 truncate">
-                                {formatCellValue(val, bodyField?.type)}
+                              <span className="text-gray-900 truncate" title={bodyField?.maxLength && String(val).length > bodyField.maxLength ? String(val) : undefined}>
+                                {formatCellValue(val, bodyField?.type, bodyField?.maxLength)}
                               </span>
                               </span>
                             </div>
                             </div>
                           )
                           )
@@ -882,7 +899,7 @@ export function DocumentListRenderer({
                       >
                       >
                         {/* Title */}
                         {/* Title */}
                         <p className="font-medium text-gray-900">
                         <p className="font-medium text-gray-900">
-                          {formatCellValue(getColumnValue(doc, titleField), field?.type)}
+                          {formatCellValue(getColumnValue(doc, titleField), field?.type, field?.maxLength)}
                         </p>
                         </p>
 
 
                         {/* Body fields */}
                         {/* Body fields */}
@@ -906,8 +923,8 @@ export function DocumentListRenderer({
                               return (
                               return (
                                 <div key={colName} className="text-xs text-gray-500">
                                 <div key={colName} className="text-xs text-gray-500">
                                   <span>{bodyField?.label || colName}: </span>
                                   <span>{bodyField?.label || colName}: </span>
-                                  <span className="text-gray-700">
-                                    {formatCellValue(val, bodyField?.type)}
+                                  <span className="text-gray-700" title={bodyField?.maxLength && String(val).length > bodyField.maxLength ? String(val) : undefined}>
+                                    {formatCellValue(val, bodyField?.type, bodyField?.maxLength)}
                                   </span>
                                   </span>
                                 </div>
                                 </div>
                               )
                               )
@@ -968,7 +985,7 @@ export function DocumentListRenderer({
 }
 }
 
 
 // Helper function to format cell values for display
 // Helper function to format cell values for display
-function formatCellValue(value: unknown, fieldType?: string): string {
+function formatCellValue(value: unknown, fieldType?: string, maxLength?: number): string {
   if (value === null || value === undefined) {
   if (value === null || value === undefined) {
     return '-'
     return '-'
   }
   }
@@ -981,10 +998,25 @@ function formatCellValue(value: unknown, fieldType?: string): string {
   if (value instanceof Date) {
   if (value instanceof Date) {
     return value.toLocaleDateString()
     return value.toLocaleDateString()
   }
   }
+
+  let result: string
   if (typeof value === 'object') {
   if (typeof value === 'object') {
-    return JSON.stringify(value)
+    result = JSON.stringify(value)
+  } else {
+    result = String(value)
+  }
+
+  // Strip HTML tags for display if content contains HTML
+  if (result.includes('<') && result.includes('>')) {
+    result = result.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
+  }
+
+  // Apply maxLength truncation if specified
+  if (maxLength && maxLength > 0 && result.length > maxLength) {
+    return result.substring(0, maxLength) + '…'
   }
   }
-  return String(value)
+
+  return result
 }
 }
 
 
 export default DocumentListRenderer
 export default DocumentListRenderer

+ 1 - 0
webui/src/components/PageRenderer/index.tsx

@@ -749,6 +749,7 @@ export function PageRenderer({ page, isEditing = false, onComponentClick, onReor
           return <TextBlockRenderer config={component.config as unknown as TextBlockConfig} />
           return <TextBlockRenderer config={component.config as unknown as TextBlockConfig} />
         case 'spacer':
         case 'spacer':
           return <SpacerRenderer config={component.config as unknown as SpacerConfig} />
           return <SpacerRenderer config={component.config as unknown as SpacerConfig} />
+        case 'view': // Legacy alias - LLM sometimes uses "view" instead of "document-list"
         case 'document-list': {
         case 'document-list': {
           const config = component.config as unknown as DocumentListConfig
           const config = component.config as unknown as DocumentListConfig
           return (
           return (

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

@@ -31,6 +31,7 @@ const menuPermissions: Record<string, string[]> = {
   '/views': ['system:views:read'],
   '/views': ['system:views:read'],
   '/pages': ['system:pages:read', 'page:*:read_all', 'page:*:read_own'],
   '/pages': ['system:pages:read', 'page:*:read_all', 'page:*:read_own'],
   '/api-keys': ['system:api_keys:read'],
   '/api-keys': ['system:api_keys:read'],
+  '/archives': ['system:archives:read'],
 }
 }
 
 
 const navigation: NavItem[] = [
 const navigation: NavItem[] = [
@@ -160,6 +161,20 @@ const navigation: NavItem[] = [
       </svg>
       </svg>
     ),
     ),
   },
   },
+  {
+    name: 'Archives',
+    path: '/archives',
+    icon: (
+      <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+        <path
+          strokeLinecap="round"
+          strokeLinejoin="round"
+          strokeWidth={2}
+          d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"
+        />
+      </svg>
+    ),
+  },
 ]
 ]
 
 
 // Icon renderer for dynamic pages
 // Icon renderer for dynamic pages

+ 96 - 10
webui/src/contexts/ChatContext.tsx

@@ -4,12 +4,14 @@ import { createContext, useContext, useState, useCallback, useEffect, ReactNode,
 import { useNavigate } from 'react-router-dom'
 import { useNavigate } from 'react-router-dom'
 import * as chatApi from '@/api/chat'
 import * as chatApi from '@/api/chat'
 import * as toolsApi from '@/api/tools'
 import * as toolsApi from '@/api/tools'
+import apiClient from '@/api/client'
 import type { ChatSession, ChatMessage, PageContext, ContextInfo, ToolCall } from '@/types/chat'
 import type { ChatSession, ChatMessage, PageContext, ContextInfo, ToolCall } from '@/types/chat'
 import type { ToolExecutionState, ToolResult, NavigationData } from '@/types/tools'
 import type { ToolExecutionState, ToolResult, NavigationData } from '@/types/tools'
+import type { Page } from '@/types'
 // Note: isNavigationTool can be imported from '@/types/tools' when needed for client-side tool execution
 // Note: isNavigationTool can be imported from '@/types/tools' when needed for client-side tool execution
 import { useAuth } from './AuthContext'
 import { useAuth } from './AuthContext'
 import { useWorkspace } from './WorkspaceContext'
 import { useWorkspace } from './WorkspaceContext'
-import { useWebSocket, type ChatChunkEvent, type ChatStateEvent } from './WebSocketContext'
+import { useWebSocket, type ChatChunkEvent, type ChatStateEvent, type DocumentEvent } from './WebSocketContext'
 
 
 interface WorkspaceSettings {
 interface WorkspaceSettings {
   llm_provider_id?: string
   llm_provider_id?: string
@@ -74,7 +76,10 @@ export function ChatProvider({ children }: ChatProviderProps) {
   const { isAuthenticated, isLoading: isAuthLoading, user } = useAuth()
   const { isAuthenticated, isLoading: isAuthLoading, user } = useAuth()
   const { currentWorkspace } = useWorkspace()
   const { currentWorkspace } = useWorkspace()
   const navigate = useNavigate()
   const navigate = useNavigate()
-  const { subscribeChat, unsubscribeChat, onChatChunk, onChatState, isConnected: wsConnected } = useWebSocket()
+  const { subscribeChat, unsubscribeChat, onChatChunk, onChatState, subscribe, unsubscribe, onDocumentEvent, isConnected: wsConnected } = useWebSocket()
+
+  // View ID to page slug mapping for navigation
+  const [viewToPageMap, setViewToPageMap] = useState<Map<string, string>>(new Map())
 
 
   // Get workspace LLM settings
   // Get workspace LLM settings
   const workspaceSettings = currentWorkspace?.settings as WorkspaceSettings | undefined
   const workspaceSettings = currentWorkspace?.settings as WorkspaceSettings | undefined
@@ -273,20 +278,74 @@ export function ChatProvider({ children }: ChatProviderProps) {
     }
     }
   }, [])
   }, [])
 
 
+  // Fetch pages and build view-to-page mapping
+  const buildViewToPageMap = useCallback(async () => {
+    if (!currentWorkspace?.id) return
+
+    try {
+      const response = await apiClient.get<{ pages: Page[] }>(
+        `/workspaces/${currentWorkspace.id}/pages`
+      )
+      const pages = response.pages || []
+      const map = new Map<string, string>()
+
+      console.log('[Chat] Building view-to-page map from', pages.length, 'pages')
+
+      // Build mapping from view_id to page slug
+      for (const page of pages) {
+        console.log('[Chat] Page:', page.slug, 'layout:', page.layout)
+        if (page.layout?.components) {
+          for (const component of page.layout.components) {
+            console.log('[Chat] Component:', component.type, component.config)
+            if (component.type === 'document-list' && component.config?.view_id) {
+              const viewId = component.config.view_id as string
+              console.log('[Chat] Found view_id mapping:', viewId, '->', page.slug)
+              // Only map to the first page that uses this view (prefer showing in sidebar)
+              if (!map.has(viewId)) {
+                map.set(viewId, page.slug)
+              }
+            }
+          }
+        }
+      }
+
+      console.log('[Chat] View-to-page map built with', map.size, 'entries:', Array.from(map.entries()))
+      setViewToPageMap(map)
+    } catch (error) {
+      console.error('Failed to build view-to-page map:', error)
+    }
+  }, [currentWorkspace?.id])
+
   // Handle navigation from tool results
   // Handle navigation from tool results
   const handleNavigation = useCallback((nav: NavigationData) => {
   const handleNavigation = useCallback((nav: NavigationData) => {
+    console.log('[Chat] handleNavigation called with:', nav)
+    console.log('[Chat] viewToPageMap has', viewToPageMap.size, 'entries')
     switch (nav.type) {
     switch (nav.type) {
       case 'page':
       case 'page':
         navigate(`/p/${nav.slug}`)
         navigate(`/p/${nav.slug}`)
         break
         break
       case 'view':
       case 'view':
         if (nav.view_id) {
         if (nav.view_id) {
-          const params = new URLSearchParams()
-          if (nav.filter) {
-            params.set('filter', JSON.stringify(nav.filter))
+          // Check if there's a page that displays this view
+          const pageSlug = viewToPageMap.get(nav.view_id)
+          console.log('[Chat] Looking up view_id', nav.view_id, 'found page slug:', pageSlug)
+          if (pageSlug) {
+            // Navigate to the custom page
+            const params = new URLSearchParams()
+            if (nav.filter) {
+              params.set('filter', JSON.stringify(nav.filter))
+            }
+            const query = params.toString()
+            navigate(`/p/${pageSlug}${query ? `?${query}` : ''}`)
+          } else {
+            // Fall back to generic view route
+            const params = new URLSearchParams()
+            if (nav.filter) {
+              params.set('filter', JSON.stringify(nav.filter))
+            }
+            const query = params.toString()
+            navigate(`/views/${nav.view_id}${query ? `?${query}` : ''}`)
           }
           }
-          const query = params.toString()
-          navigate(`/views/${nav.view_id}${query ? `?${query}` : ''}`)
         }
         }
         break
         break
       case 'document_create':
       case 'document_create':
@@ -310,7 +369,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
         }
         }
         break
         break
     }
     }
-  }, [navigate])
+  }, [navigate, viewToPageMap])
 
 
   // Execute a tool call
   // Execute a tool call
   const executeTool = useCallback(async (
   const executeTool = useCallback(async (
@@ -599,12 +658,39 @@ export function ChatProvider({ children }: ChatProviderProps) {
     }
     }
   }, [isLoadingSessions, sessions, currentSession, switchSession])
   }, [isLoadingSessions, sessions, currentSession, switchSession])
 
 
-  // Reset current session when workspace changes
+  // Reset current session when workspace changes and build view-to-page map
   useEffect(() => {
   useEffect(() => {
     // Clear current session when workspace changes, sessions will be refreshed
     // Clear current session when workspace changes, sessions will be refreshed
     setCurrentSession(null)
     setCurrentSession(null)
     setMessages([])
     setMessages([])
-  }, [currentWorkspace?.id])
+    // Build view-to-page map for navigation
+    buildViewToPageMap()
+  }, [currentWorkspace?.id, buildViewToPageMap])
+
+  // Subscribe to session list changes for real-time updates across browsers
+  useEffect(() => {
+    const workspaceId = currentWorkspace?.id
+    if (!workspaceId || !wsConnected) return
+
+    // Subscribe to __llm_sessions collection for real-time session updates
+    subscribe(workspaceId, '__llm_sessions')
+
+    const handleSessionEvent = (event: DocumentEvent) => {
+      if (event.collection !== '__llm_sessions' || event.workspace_id !== workspaceId) return
+
+      // Refresh sessions list when sessions are created, updated, or deleted
+      if (event.action === 'create' || event.action === 'update' || event.action === 'delete') {
+        refreshSessions()
+      }
+    }
+
+    const cleanup = onDocumentEvent(handleSessionEvent)
+
+    return () => {
+      unsubscribe(workspaceId, '__llm_sessions')
+      cleanup()
+    }
+  }, [currentWorkspace?.id, wsConnected, subscribe, unsubscribe, onDocumentEvent, refreshSessions])
 
 
   // Cleanup on unmount
   // Cleanup on unmount
   useEffect(() => {
   useEffect(() => {

+ 34 - 0
webui/src/contexts/WebSocketContext.tsx

@@ -51,6 +51,18 @@ export interface ChatStateEvent {
   current_event_id?: string
   current_event_id?: string
 }
 }
 
 
+export interface WorkspaceEvent {
+  workspace_id: string
+  action: 'create' | 'update' | 'delete'
+  data?: {
+    id: string
+    name: string
+    settings?: unknown
+    created_at?: string
+    updated_at?: string
+  }
+}
+
 interface WebSocketContextType {
 interface WebSocketContextType {
   isConnected: boolean
   isConnected: boolean
   notifications: Notification[]
   notifications: Notification[]
@@ -65,6 +77,7 @@ interface WebSocketContextType {
   onDocumentEvent: (callback: (event: DocumentEvent) => void) => () => void
   onDocumentEvent: (callback: (event: DocumentEvent) => void) => () => void
   onChatChunk: (callback: (event: ChatChunkEvent) => void) => () => void
   onChatChunk: (callback: (event: ChatChunkEvent) => void) => () => void
   onChatState: (callback: (event: ChatStateEvent) => void) => () => void
   onChatState: (callback: (event: ChatStateEvent) => void) => () => void
+  onWorkspaceEvent: (callback: (event: WorkspaceEvent) => void) => () => void
 }
 }
 
 
 const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined)
 const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined)
@@ -80,6 +93,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
   const documentEventCallbacksRef = useRef<Set<(event: DocumentEvent) => void>>(new Set())
   const documentEventCallbacksRef = useRef<Set<(event: DocumentEvent) => void>>(new Set())
   const chatChunkCallbacksRef = useRef<Set<(event: ChatChunkEvent) => void>>(new Set())
   const chatChunkCallbacksRef = useRef<Set<(event: ChatChunkEvent) => void>>(new Set())
   const chatStateCallbacksRef = useRef<Set<(event: ChatStateEvent) => void>>(new Set())
   const chatStateCallbacksRef = useRef<Set<(event: ChatStateEvent) => void>>(new Set())
+  const workspaceEventCallbacksRef = useRef<Set<(event: WorkspaceEvent) => void>>(new Set())
 
 
   const [isConnected, setIsConnected] = useState(false)
   const [isConnected, setIsConnected] = useState(false)
   const [notifications, setNotifications] = useState<Notification[]>([])
   const [notifications, setNotifications] = useState<Notification[]>([])
@@ -214,6 +228,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
         break
         break
       }
       }
 
 
+      case 'workspace': {
+        // Notify workspace event listeners
+        const wsEvent: WorkspaceEvent = {
+          workspace_id: String(message.workspace_id || ''),
+          action: message.action as 'create' | 'update' | 'delete',
+          data: message.data as WorkspaceEvent['data'],
+        }
+        workspaceEventCallbacksRef.current.forEach((cb) => cb(wsEvent))
+        break
+      }
+
       case 'subscribe_chat':
       case 'subscribe_chat':
         console.log(`Subscribed to chat session ${message.workspace_id}:${message.session_id}`)
         console.log(`Subscribed to chat session ${message.workspace_id}:${message.session_id}`)
         break
         break
@@ -343,6 +368,14 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
     }
     }
   }, [])
   }, [])
 
 
+  // Register workspace event callback
+  const onWorkspaceEvent = useCallback((callback: (event: WorkspaceEvent) => void) => {
+    workspaceEventCallbacksRef.current.add(callback)
+    return () => {
+      workspaceEventCallbacksRef.current.delete(callback)
+    }
+  }, [])
+
   // Connect on auth change
   // Connect on auth change
   useEffect(() => {
   useEffect(() => {
     // Wait for auth to finish loading
     // Wait for auth to finish loading
@@ -396,6 +429,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
     onDocumentEvent,
     onDocumentEvent,
     onChatChunk,
     onChatChunk,
     onChatState,
     onChatState,
+    onWorkspaceEvent,
   }
   }
 
 
   return <WebSocketContext.Provider value={value}>{children}</WebSocketContext.Provider>
   return <WebSocketContext.Provider value={value}>{children}</WebSocketContext.Provider>

+ 79 - 0
webui/src/contexts/WorkspaceContext.tsx

@@ -3,6 +3,7 @@
 import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
 import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'
 import apiClient from '@/api/client'
 import apiClient from '@/api/client'
 import { useAuth } from './AuthContext'
 import { useAuth } from './AuthContext'
+import { useWebSocket, type WorkspaceEvent } from './WebSocketContext'
 import type { Workspace } from '@/types'
 import type { Workspace } from '@/types'
 
 
 interface WorkspaceContextValue {
 interface WorkspaceContextValue {
@@ -24,6 +25,7 @@ interface WorkspaceProviderProps {
 
 
 export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
 export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
   const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
   const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
+  const { onWorkspaceEvent } = useWebSocket()
   const [workspaces, setWorkspaces] = useState<Workspace[]>([])
   const [workspaces, setWorkspaces] = useState<Workspace[]>([])
   const [currentWorkspace, setCurrentWorkspaceState] = useState<Workspace | null>(null)
   const [currentWorkspace, setCurrentWorkspaceState] = useState<Workspace | null>(null)
   const [isLoading, setIsLoading] = useState(false)
   const [isLoading, setIsLoading] = useState(false)
@@ -76,6 +78,83 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isAuthenticated, isAuthLoading])
   }, [isAuthenticated, isAuthLoading])
 
 
+  // Handle real-time workspace events
+  useEffect(() => {
+    const unsubscribe = onWorkspaceEvent((event: WorkspaceEvent) => {
+      switch (event.action) {
+        case 'create':
+          // Add new workspace to the list if data is provided
+          if (event.data) {
+            const newWorkspace: Workspace = {
+              id: event.data.id,
+              name: event.data.name,
+              settings: (event.data.settings as Record<string, unknown>) || {},
+              created_at: event.data.created_at || new Date().toISOString(),
+              updated_at: event.data.updated_at || new Date().toISOString(),
+            }
+            setWorkspaces((prev) => {
+              // Avoid duplicates
+              if (prev.some((w) => w.id === newWorkspace.id)) {
+                return prev
+              }
+              return [...prev, newWorkspace]
+            })
+          }
+          break
+
+        case 'update':
+          // Update existing workspace in the list
+          if (event.data) {
+            const updatedSettings = event.data.settings as Record<string, unknown> | undefined
+            setWorkspaces((prev) =>
+              prev.map((w) =>
+                w.id === event.workspace_id
+                  ? {
+                      ...w,
+                      name: event.data!.name,
+                      settings: updatedSettings ?? w.settings,
+                      updated_at: event.data!.updated_at || new Date().toISOString(),
+                    }
+                  : w
+              )
+            )
+            // Also update current workspace if it's the one being updated
+            if (currentWorkspace?.id === event.workspace_id) {
+              setCurrentWorkspaceState((prev) =>
+                prev
+                  ? {
+                      ...prev,
+                      name: event.data!.name,
+                      settings: updatedSettings ?? prev.settings,
+                      updated_at: event.data!.updated_at || new Date().toISOString(),
+                    }
+                  : prev
+              )
+            }
+          }
+          break
+
+        case 'delete':
+          // Remove workspace from the list
+          setWorkspaces((prev) => prev.filter((w) => w.id !== event.workspace_id))
+          // If the deleted workspace was the current one, switch to another
+          if (currentWorkspace?.id === event.workspace_id) {
+            setWorkspaces((prev) => {
+              if (prev.length > 0) {
+                setCurrentWorkspace(prev[0])
+              } else {
+                setCurrentWorkspace(null)
+              }
+              return prev
+            })
+          }
+          break
+      }
+    })
+
+    return unsubscribe
+  }, [onWorkspaceEvent, currentWorkspace?.id, setCurrentWorkspace])
+
   return (
   return (
     <WorkspaceContext.Provider
     <WorkspaceContext.Provider
       value={{
       value={{

+ 4 - 4
webui/src/main.tsx

@@ -13,15 +13,15 @@ createRoot(document.getElementById('root')!).render(
   <StrictMode>
   <StrictMode>
     <BrowserRouter>
     <BrowserRouter>
       <AuthProvider>
       <AuthProvider>
-        <WorkspaceProvider>
-          <WebSocketProvider>
+        <WebSocketProvider>
+          <WorkspaceProvider>
             <ChatProvider>
             <ChatProvider>
               <ToastProvider>
               <ToastProvider>
                 <App />
                 <App />
               </ToastProvider>
               </ToastProvider>
             </ChatProvider>
             </ChatProvider>
-          </WebSocketProvider>
-        </WorkspaceProvider>
+          </WorkspaceProvider>
+        </WebSocketProvider>
       </AuthProvider>
       </AuthProvider>
     </BrowserRouter>
     </BrowserRouter>
   </StrictMode>
   </StrictMode>

+ 597 - 0
webui/src/pages/Archives.tsx

@@ -0,0 +1,597 @@
+// Archives management page for viewing and restoring deleted items
+
+import React, { useState, useEffect, useCallback } from 'react'
+import apiClient from '@/api/client'
+import { useAuth } from '@/contexts/AuthContext'
+import { useWorkspace } from '@/contexts/WorkspaceContext'
+import Button from '@/components/Button'
+import { formatDateTime } from '@/utils/format'
+
+interface Archive {
+  id: string
+  entity_type: string
+  entity_id: string
+  workspace_id: string
+  parent_archive_id?: string
+  name?: string
+  children_count: number
+  created_at: string
+  created_by: string
+  created_by_name?: string
+  expires_at: string
+  status: string
+}
+
+interface ArchiveDetail extends Archive {
+  data: Record<string, unknown>
+  restored_at?: string
+  exported_at?: string
+}
+
+const ENTITY_TYPE_LABELS: Record<string, string> = {
+  workspace: 'Workspace',
+  page: 'Page',
+  view: 'View',
+  collection: 'Collection',
+  document: 'Document',
+  session: 'Chat Session',
+}
+
+const ENTITY_TYPE_ICONS: Record<string, React.ReactElement> = {
+  workspace: (
+    <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
+    </svg>
+  ),
+  page: (
+    <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+    </svg>
+  ),
+  view: (
+    <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
+    </svg>
+  ),
+  collection: (
+    <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
+    </svg>
+  ),
+  document: (
+    <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
+    </svg>
+  ),
+  session: (
+    <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
+    </svg>
+  ),
+}
+
+function RestoreConfirmModal({
+  archive,
+  onClose,
+  onConfirm,
+}: {
+  archive: Archive
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  const [restoreChildren, setRestoreChildren] = useState(true)
+
+  const handleRestore = async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await apiClient.post(`/archives/${archive.id}/restore`, { restore_children: restoreChildren })
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to restore archive')
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Restore Archive</h2>
+        <p className="mt-2 text-gray-600">
+          Are you sure you want to restore <strong>{archive.name || archive.entity_id}</strong>?
+          This will recreate the {ENTITY_TYPE_LABELS[archive.entity_type] || archive.entity_type}.
+        </p>
+
+        {archive.children_count > 0 && (
+          <label className="mt-4 flex cursor-pointer items-center gap-3">
+            <input
+              type="checkbox"
+              checked={restoreChildren}
+              onChange={(e) => setRestoreChildren(e.target.checked)}
+              className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
+            />
+            <span className="text-gray-700">
+              Also restore {archive.children_count} child item{archive.children_count !== 1 ? 's' : ''}
+            </span>
+          </label>
+        )}
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button type="button" onClick={handleRestore} isLoading={isLoading}>
+            Restore
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function DeleteConfirmModal({
+  archive,
+  onClose,
+  onConfirm,
+}: {
+  archive: Archive
+  onClose: () => void
+  onConfirm: () => void
+}) {
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+
+  const handleDelete = async () => {
+    setIsLoading(true)
+    setError(null)
+
+    try {
+      await apiClient.delete(`/archives/${archive.id}`)
+      onConfirm()
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to delete archive')
+      setIsLoading(false)
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
+        <h2 className="text-xl font-semibold text-gray-900">Permanently Delete</h2>
+        <p className="mt-2 text-gray-600">
+          Are you sure you want to permanently delete <strong>{archive.name || archive.entity_id}</strong>?
+          This action cannot be undone.
+        </p>
+
+        {archive.children_count > 0 && (
+          <div className="mt-4 rounded-lg bg-yellow-50 p-4">
+            <p className="text-sm text-yellow-800">
+              <strong>Warning:</strong> This will also permanently delete {archive.children_count} child archive{archive.children_count !== 1 ? 's' : ''}.
+            </p>
+          </div>
+        )}
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        <div className="mt-6 flex justify-end gap-3">
+          <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
+            Cancel
+          </Button>
+          <Button type="button" variant="danger" onClick={handleDelete} isLoading={isLoading}>
+            Delete Permanently
+          </Button>
+        </div>
+      </div>
+    </div>
+  )
+}
+
+function ArchiveDetailModal({
+  archiveId,
+  onClose,
+}: {
+  archiveId: string
+  onClose: () => void
+}) {
+  const [archive, setArchive] = useState<ArchiveDetail | null>(null)
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+
+  useEffect(() => {
+    const fetchArchive = async () => {
+      try {
+        const response = await apiClient.get<ArchiveDetail>(`/archives/${archiveId}`)
+        setArchive(response)
+      } catch (err) {
+        setError(err instanceof Error ? err.message : 'Failed to load archive details')
+      } finally {
+        setIsLoading(false)
+      }
+    }
+    fetchArchive()
+  }, [archiveId])
+
+  const handleExport = async () => {
+    try {
+      const response = await apiClient.get<{ export_data: Record<string, unknown> }>(`/archives/${archiveId}/export`)
+      const blob = new Blob([JSON.stringify(response, null, 2)], { type: 'application/json' })
+      const url = URL.createObjectURL(blob)
+      const a = document.createElement('a')
+      a.href = url
+      a.download = `archive_${archiveId}.json`
+      document.body.appendChild(a)
+      a.click()
+      document.body.removeChild(a)
+      URL.revokeObjectURL(url)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to export archive')
+    }
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
+      <div className="w-full max-w-2xl max-h-[80vh] overflow-auto rounded-lg bg-white p-6 shadow-xl">
+        <div className="flex items-center justify-between">
+          <h2 className="text-xl font-semibold text-gray-900">Archive Details</h2>
+          <button onClick={onClose} className="rounded p-1 hover:bg-gray-100">
+            <svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+            </svg>
+          </button>
+        </div>
+
+        {isLoading && (
+          <div className="flex items-center justify-center py-12">
+            <svg className="h-8 w-8 animate-spin text-primary-600" 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>
+          </div>
+        )}
+
+        {error && (
+          <div className="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
+        )}
+
+        {archive && (
+          <div className="mt-4 space-y-4">
+            <div className="grid grid-cols-2 gap-4 text-sm">
+              <div>
+                <span className="text-gray-500">Type:</span>
+                <span className="ml-2 font-medium">{ENTITY_TYPE_LABELS[archive.entity_type] || archive.entity_type}</span>
+              </div>
+              <div>
+                <span className="text-gray-500">Status:</span>
+                <span className={`ml-2 inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
+                  archive.status === 'active' ? 'bg-green-100 text-green-800' :
+                  archive.status === 'expired' ? 'bg-red-100 text-red-800' :
+                  archive.status === 'restored' ? 'bg-blue-100 text-blue-800' :
+                  'bg-gray-100 text-gray-800'
+                }`}>
+                  {archive.status}
+                </span>
+              </div>
+              <div>
+                <span className="text-gray-500">Deleted:</span>
+                <span className="ml-2">{formatDateTime(archive.created_at)}</span>
+              </div>
+              <div>
+                <span className="text-gray-500">Expires:</span>
+                <span className="ml-2">{formatDateTime(archive.expires_at)}</span>
+              </div>
+              <div>
+                <span className="text-gray-500">Deleted by:</span>
+                <span className="ml-2">{archive.created_by_name || archive.created_by}</span>
+              </div>
+              <div>
+                <span className="text-gray-500">Children:</span>
+                <span className="ml-2">{archive.children_count}</span>
+              </div>
+            </div>
+
+            <div>
+              <h3 className="mb-2 text-sm font-medium text-gray-700">Archived Data</h3>
+              <pre className="max-h-64 overflow-auto rounded-lg bg-gray-50 p-4 text-xs">
+                {JSON.stringify(archive.data, null, 2)}
+              </pre>
+            </div>
+
+            <div className="flex justify-end gap-3 pt-4 border-t">
+              <Button type="button" variant="secondary" onClick={handleExport}>
+                <svg className="-ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
+                </svg>
+                Export JSON
+              </Button>
+              <Button type="button" onClick={onClose}>
+                Close
+              </Button>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}
+
+function getDaysUntilExpiration(expiresAt: string): number {
+  const now = new Date()
+  const expires = new Date(expiresAt)
+  const diffTime = expires.getTime() - now.getTime()
+  return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
+}
+
+function Archives() {
+  const { user } = useAuth()
+  const { currentWorkspace } = useWorkspace()
+  const [archives, setArchives] = useState<Archive[]>([])
+  const [totalCount, setTotalCount] = useState(0)
+  const [isLoading, setIsLoading] = useState(true)
+  const [error, setError] = useState<string | null>(null)
+  const [entityTypeFilter, setEntityTypeFilter] = useState<string>('')
+  const [restoringArchive, setRestoringArchive] = useState<Archive | null>(null)
+  const [deletingArchive, setDeletingArchive] = useState<Archive | null>(null)
+  const [viewingArchiveId, setViewingArchiveId] = useState<string | null>(null)
+
+  const fetchArchives = useCallback(async () => {
+    if (!currentWorkspace) {
+      setIsLoading(false)
+      return
+    }
+
+    setIsLoading(true)
+    setError(null)
+    try {
+      const params = new URLSearchParams({ workspace_id: currentWorkspace.id })
+      if (entityTypeFilter) {
+        params.append('entity_type', entityTypeFilter)
+      }
+      const response = await apiClient.get<{ archives: Archive[]; total_count: number }>(`/archives?${params}`)
+      setArchives(response.archives || [])
+      setTotalCount(response.total_count || 0)
+    } catch (err) {
+      setError(err instanceof Error ? err.message : 'Failed to fetch archives')
+    } finally {
+      setIsLoading(false)
+    }
+  }, [currentWorkspace, entityTypeFilter])
+
+  useEffect(() => {
+    fetchArchives()
+  }, [fetchArchives])
+
+  const handleRestored = () => {
+    setRestoringArchive(null)
+    fetchArchives()
+  }
+
+  const handleDeleted = () => {
+    setDeletingArchive(null)
+    fetchArchives()
+  }
+
+  if (!user) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Archives</h1>
+          <p className="mt-1 text-gray-600">View and restore deleted items</p>
+        </div>
+        <div className="rounded-lg bg-yellow-50 p-6 text-center">
+          <p className="text-yellow-800">Please log in to view archives.</p>
+        </div>
+      </div>
+    )
+  }
+
+  if (!currentWorkspace) {
+    return (
+      <div className="space-y-6">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Archives</h1>
+          <p className="mt-1 text-gray-600">View and restore deleted items</p>
+        </div>
+        <div className="rounded-lg bg-blue-50 p-6 text-center">
+          <p className="text-blue-800">Please select a workspace to view archives.</p>
+        </div>
+      </div>
+    )
+  }
+
+  return (
+    <div className="space-y-6">
+      <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
+        <div>
+          <h1 className="text-2xl font-bold text-gray-900">Archives</h1>
+          <p className="mt-1 text-gray-600">
+            View and restore deleted items in {currentWorkspace.name}
+          </p>
+        </div>
+        <div className="flex items-center gap-3">
+          <select
+            value={entityTypeFilter}
+            onChange={(e) => setEntityTypeFilter(e.target.value)}
+            className="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
+          >
+            <option value="">All Types</option>
+            <option value="workspace">Workspaces</option>
+            <option value="page">Pages</option>
+            <option value="view">Views</option>
+            <option value="collection">Collections</option>
+            <option value="document">Documents</option>
+            <option value="session">Chat Sessions</option>
+          </select>
+        </div>
+      </div>
+
+      {/* Error state */}
+      {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
+
+      {/* Loading state */}
+      {isLoading && (
+        <div className="flex items-center justify-center py-12">
+          <svg className="h-8 w-8 animate-spin text-primary-600" 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>
+        </div>
+      )}
+
+      {/* Archive list */}
+      {!isLoading && !error && (
+        <div className="overflow-hidden rounded-lg bg-white shadow">
+          {archives.length === 0 ? (
+            <div className="px-6 py-12 text-center">
+              <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
+              </svg>
+              <p className="mt-4 text-gray-500">No archived items</p>
+              <p className="mt-1 text-sm text-gray-400">
+                Deleted items will appear here for recovery.
+              </p>
+            </div>
+          ) : (
+            <>
+              <div className="border-b px-6 py-3 text-sm text-gray-500">
+                {totalCount} archived item{totalCount !== 1 ? 's' : ''}
+              </div>
+              <table className="min-w-full divide-y divide-gray-200">
+                <thead className="bg-gray-50">
+                  <tr>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Item
+                    </th>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Type
+                    </th>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Deleted
+                    </th>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Expires
+                    </th>
+                    <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Status
+                    </th>
+                    <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
+                      Actions
+                    </th>
+                  </tr>
+                </thead>
+                <tbody className="divide-y divide-gray-200 bg-white">
+                  {archives.map((archive) => {
+                    const daysUntilExpiration = getDaysUntilExpiration(archive.expires_at)
+                    return (
+                      <tr key={archive.id} className="hover:bg-gray-50">
+                        <td className="whitespace-nowrap px-6 py-4">
+                          <div className="flex items-center gap-3">
+                            <span className="text-gray-400">
+                              {ENTITY_TYPE_ICONS[archive.entity_type] || ENTITY_TYPE_ICONS.document}
+                            </span>
+                            <div>
+                              <div className="font-medium text-gray-900">
+                                {archive.name || archive.entity_id}
+                              </div>
+                              {archive.children_count > 0 && (
+                                <div className="text-xs text-gray-500">
+                                  +{archive.children_count} child item{archive.children_count !== 1 ? 's' : ''}
+                                </div>
+                              )}
+                            </div>
+                          </div>
+                        </td>
+                        <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
+                          {ENTITY_TYPE_LABELS[archive.entity_type] || archive.entity_type}
+                        </td>
+                        <td className="px-6 py-4 text-sm text-gray-500">
+                          <div>{formatDateTime(archive.created_at)}</div>
+                          {archive.created_by_name && (
+                            <div className="text-xs text-gray-400">by {archive.created_by_name}</div>
+                          )}
+                        </td>
+                        <td className="px-6 py-4 text-sm">
+                          <div className={daysUntilExpiration <= 7 ? 'text-red-600' : 'text-gray-500'}>
+                            {daysUntilExpiration <= 0 ? 'Expired' : `${daysUntilExpiration} days`}
+                          </div>
+                        </td>
+                        <td className="whitespace-nowrap px-6 py-4">
+                          <span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${
+                            archive.status === 'active' ? 'bg-green-100 text-green-800' :
+                            archive.status === 'expired' ? 'bg-red-100 text-red-800' :
+                            archive.status === 'restored' ? 'bg-blue-100 text-blue-800' :
+                            'bg-gray-100 text-gray-800'
+                          }`}>
+                            {archive.status}
+                          </span>
+                        </td>
+                        <td className="whitespace-nowrap px-6 py-4 text-right">
+                          <div className="flex justify-end gap-2">
+                            <button
+                              onClick={() => setViewingArchiveId(archive.id)}
+                              className="rounded px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
+                            >
+                              View
+                            </button>
+                            {archive.status === 'active' && (
+                              <>
+                                <button
+                                  onClick={() => setRestoringArchive(archive)}
+                                  className="rounded px-3 py-1 text-sm text-primary-600 hover:bg-primary-50"
+                                >
+                                  Restore
+                                </button>
+                                <button
+                                  onClick={() => setDeletingArchive(archive)}
+                                  className="rounded px-3 py-1 text-sm text-red-600 hover:bg-red-50"
+                                >
+                                  Delete
+                                </button>
+                              </>
+                            )}
+                          </div>
+                        </td>
+                      </tr>
+                    )
+                  })}
+                </tbody>
+              </table>
+            </>
+          )}
+        </div>
+      )}
+
+      {/* Modals */}
+      {restoringArchive && (
+        <RestoreConfirmModal
+          archive={restoringArchive}
+          onClose={() => setRestoringArchive(null)}
+          onConfirm={handleRestored}
+        />
+      )}
+      {deletingArchive && (
+        <DeleteConfirmModal
+          archive={deletingArchive}
+          onClose={() => setDeletingArchive(null)}
+          onConfirm={handleDeleted}
+        />
+      )}
+      {viewingArchiveId && (
+        <ArchiveDetailModal
+          archiveId={viewingArchiveId}
+          onClose={() => setViewingArchiveId(null)}
+        />
+      )}
+    </div>
+  )
+}
+
+export default Archives

+ 191 - 0
webui/src/pages/Views.tsx

@@ -102,6 +102,12 @@ interface SchemaField {
   options?: string[]
   options?: string[]
   reference_collection?: string
   reference_collection?: string
   markdown?: boolean // Enable markdown editing/rendering for textarea fields
   markdown?: boolean // Enable markdown editing/rendering for textarea fields
+  // List view display options
+  hidden?: boolean // Hide field from list/table views (still visible in edit/create forms)
+  maxLength?: number // Maximum characters to display in list views (truncate with ellipsis)
+  // Detail view display options
+  hidden_in_detail?: boolean // Hide field from single item detail view
+  detail_widget?: string // Widget to use in detail view (defaults to same as edit widget)
 }
 }
 
 
 interface ViewSchema {
 interface ViewSchema {
@@ -365,6 +371,80 @@ function FieldEditor({
               <span className="text-sm text-gray-700">Required field</span>
               <span className="text-sm text-gray-700">Required field</span>
             </label>
             </label>
           </div>
           </div>
+
+          {/* List View Display Options */}
+          <div className="sm:col-span-2 border-t pt-4 mt-2">
+            <h4 className="text-sm font-medium text-gray-700 mb-3">List View Display</h4>
+            <div className="grid gap-4 sm:grid-cols-2">
+              <label className="flex items-center gap-2">
+                <input
+                  type="checkbox"
+                  checked={field.hidden || false}
+                  onChange={(e) => onUpdate({ ...field, hidden: e.target.checked })}
+                  className="h-4 w-4 rounded border-gray-300"
+                />
+                <div>
+                  <span className="text-sm text-gray-700">Hide in list view</span>
+                  <p className="text-xs text-gray-500">Field won't show in tables/cards but is visible in forms</p>
+                </div>
+              </label>
+
+              <div>
+                <label className="mb-1.5 block text-sm font-medium text-gray-700">Max display length</label>
+                <input
+                  type="number"
+                  value={field.maxLength || ''}
+                  onChange={(e) => onUpdate({ ...field, maxLength: e.target.value ? parseInt(e.target.value) : undefined })}
+                  placeholder="No limit"
+                  min={1}
+                  className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+                />
+                <p className="mt-1 text-xs text-gray-500">Truncate long content with ellipsis</p>
+              </div>
+            </div>
+          </div>
+
+          {/* Detail View Display Options */}
+          <div className="sm:col-span-2 border-t pt-4 mt-2">
+            <h4 className="text-sm font-medium text-gray-700 mb-3">Detail View Display</h4>
+            <div className="grid gap-4 sm:grid-cols-2">
+              <label className="flex items-center gap-2">
+                <input
+                  type="checkbox"
+                  checked={field.hidden_in_detail || false}
+                  onChange={(e) => onUpdate({ ...field, hidden_in_detail: e.target.checked })}
+                  className="h-4 w-4 rounded border-gray-300"
+                />
+                <div>
+                  <span className="text-sm text-gray-700">Hide in detail view</span>
+                  <p className="text-xs text-gray-500">Field won't show when viewing a single item</p>
+                </div>
+              </label>
+
+              <div>
+                <label className="mb-1.5 block text-sm font-medium text-gray-700">Detail display style</label>
+                <select
+                  value={field.detail_widget || ''}
+                  onChange={(e) => onUpdate({ ...field, detail_widget: e.target.value || undefined })}
+                  className="w-full rounded-lg border border-gray-300 px-3 py-2"
+                >
+                  <option value="">Same as edit widget</option>
+                  <option value="readonly">Read-only text</option>
+                  <option value="badge">Badge/Tag</option>
+                  <option value="link">Clickable link</option>
+                  {(field.type === 'text' || field.type === 'textarea' || field.widget === 'textarea') && (
+                    <option value="markdown">Render as Markdown</option>
+                  )}
+                  {(field.type === 'text' || field.type === 'textarea' || field.widget === 'textarea') && (
+                    <option value="html">Render as HTML</option>
+                  )}
+                  {field.type === 'color' && (
+                    <option value="swatch">Color swatch</option>
+                  )}
+                </select>
+              </div>
+            </div>
+          </div>
         </div>
         </div>
       )}
       )}
     </div>
     </div>
@@ -408,6 +488,12 @@ function ViewModal({
           options: f.options,
           options: f.options,
           reference_collection: f.reference_collection,
           reference_collection: f.reference_collection,
           markdown: f.markdown,
           markdown: f.markdown,
+          // List view display options
+          hidden: f.hidden,
+          maxLength: f.maxLength,
+          // Detail view display options
+          hidden_in_detail: f.hidden_in_detail,
+          detail_widget: f.detail_widget,
         }))
         }))
     }
     }
     return []
     return []
@@ -437,6 +523,14 @@ function ViewModal({
     (view?.settings?.quick_edit_fields as string[]) || []
     (view?.settings?.quick_edit_fields as string[]) || []
   )
   )
 
 
+  // Detail view settings state
+  const [showDetailButton, setShowDetailButton] = useState<boolean>(
+    view?.settings?.show_detail_button === true
+  )
+  const [quickDetailMode, setQuickDetailMode] = useState<string>(
+    (view?.settings?.quick_detail_mode as string) || 'modal'
+  )
+
   // System fields state - built-in document metadata fields
   // System fields state - built-in document metadata fields
   const [enabledSystemFields, setEnabledSystemFields] = useState<string[]>(() => {
   const [enabledSystemFields, setEnabledSystemFields] = useState<string[]>(() => {
     if (view?.schema?.fields) {
     if (view?.schema?.fields) {
@@ -588,6 +682,20 @@ function ViewModal({
         if (f.markdown) {
         if (f.markdown) {
           field.markdown = f.markdown
           field.markdown = f.markdown
         }
         }
+        // List view display options
+        if (f.hidden) {
+          field.hidden = f.hidden
+        }
+        if (f.maxLength) {
+          field.maxLength = f.maxLength
+        }
+        // Detail view display options
+        if (f.hidden_in_detail) {
+          field.hidden_in_detail = f.hidden_in_detail
+        }
+        if (f.detail_widget) {
+          field.detail_widget = f.detail_widget
+        }
         return field
         return field
       })
       })
 
 
@@ -625,6 +733,9 @@ function ViewModal({
           quick_edit_fields: quickEditFields.filter(f =>
           quick_edit_fields: quickEditFields.filter(f =>
             schemaFields.some(sf => sf.name === f)
             schemaFields.some(sf => sf.name === f)
           ),
           ),
+          // Detail view settings
+          show_detail_button: showDetailButton,
+          quick_detail_mode: quickDetailMode,
         },
         },
       }
       }
 
 
@@ -1080,6 +1191,86 @@ function ViewModal({
                     </div>
                     </div>
                   </div>
                   </div>
                 )}
                 )}
+
+                {/* Divider between Edit and Detail View settings */}
+                <div className="border-t border-gray-200 pt-4">
+                  <h4 className="mb-4 font-medium text-gray-900">Detail View Settings</h4>
+                  <p className="mb-4 text-sm text-gray-500">
+                    Configure how individual items are displayed when clicked (read-only view)
+                  </p>
+                </div>
+
+                {/* Show detail/view button toggle */}
+                <label className="flex items-center gap-3">
+                  <input
+                    type="checkbox"
+                    checked={showDetailButton}
+                    onChange={(e) => setShowDetailButton(e.target.checked)}
+                    className="h-4 w-4 rounded border-gray-300"
+                  />
+                  <div>
+                    <span className="font-medium text-gray-900">Show view button</span>
+                    <p className="text-sm text-gray-500">
+                      Allow users to view item details (read-only) before editing
+                    </p>
+                  </div>
+                </label>
+
+                {/* Detail view mode selector - only show when detail button is enabled */}
+                {showDetailButton && (
+                  <div>
+                    <label className="mb-2 block text-sm font-medium text-gray-700">
+                      Detail view style
+                    </label>
+                    <div className="grid gap-3 sm:grid-cols-3">
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickDetailMode === 'modal' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickDetailMode"
+                          value="modal"
+                          checked={quickDetailMode === 'modal'}
+                          onChange={(e) => setQuickDetailMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Modal</span>
+                          <p className="text-xs text-gray-500">Popup dialog</p>
+                        </div>
+                      </label>
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickDetailMode === 'sidebar' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickDetailMode"
+                          value="sidebar"
+                          checked={quickDetailMode === 'sidebar'}
+                          onChange={(e) => setQuickDetailMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Sidebar</span>
+                          <p className="text-xs text-gray-500">Slide-in panel</p>
+                        </div>
+                      </label>
+                      <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickDetailMode === 'page' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
+                        <input
+                          type="radio"
+                          name="quickDetailMode"
+                          value="page"
+                          checked={quickDetailMode === 'page'}
+                          onChange={(e) => setQuickDetailMode(e.target.value)}
+                          className="mt-0.5 h-4 w-4"
+                        />
+                        <div>
+                          <span className="font-medium text-gray-900">Full page</span>
+                          <p className="text-xs text-gray-500">Dedicated page</p>
+                        </div>
+                      </label>
+                    </div>
+                    <p className="mt-3 text-sm text-gray-500">
+                      Tip: Configure which fields appear and how they're displayed using the "Detail View Display" options in each field's settings above.
+                    </p>
+                  </div>
+                )}
               </div>
               </div>
             </div>
             </div>
 
 

+ 1 - 0
webui/src/types/index.ts

@@ -237,6 +237,7 @@ export type ComponentType =
   | 'spacer'
   | 'spacer'
   | 'document-list'
   | 'document-list'
   | 'stat-card'
   | 'stat-card'
+  | 'view' // Legacy alias for document-list (LLM sometimes uses this)
 
 
 export interface PageLayout {
 export interface PageLayout {
   version: number
   version: number

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