diff --git a/apps/ios/PageSpace/Core/Models/Conversation.swift b/apps/ios/PageSpace/Core/Models/Conversation.swift index a7598dd78b..94bf45bb1f 100644 --- a/apps/ios/PageSpace/Core/Models/Conversation.swift +++ b/apps/ios/PageSpace/Core/Models/Conversation.swift @@ -39,11 +39,6 @@ struct Conversation: Identifiable, Codable { } } -struct ConversationListResponse: Codable { - let conversations: [Conversation] - let total: Int -} - struct ConversationMessagesResponse: Codable { let messages: [Message] let pagination: Pagination? diff --git a/apps/ios/PageSpace/Core/Models/Page.swift b/apps/ios/PageSpace/Core/Models/Page.swift index 7c0d42d5c5..ad99a08592 100644 --- a/apps/ios/PageSpace/Core/Models/Page.swift +++ b/apps/ios/PageSpace/Core/Models/Page.swift @@ -193,3 +193,59 @@ struct PageAIMessageRequest: Codable { struct PageAIMessagesResponse: Codable { let messages: [Message] } + +// MARK: - Multi-Drive Agents API Response + +struct MultiDriveAgentsResponse: Codable { + let success: Bool + let totalCount: Int + let driveCount: Int + let summary: String? + let agentsByDrive: [DriveAgentGroup]? + let agents: [AgentSummary]? // When groupByDrive=false +} + +struct DriveAgentGroup: Codable { + let driveId: String + let driveName: String + let driveSlug: String + let agentCount: Int + let agents: [AgentSummary] +} + +struct AgentSummary: Codable { + let id: String + let title: String? + let parentId: String? + let position: Int? + let aiProvider: String? + let aiModel: String? + let hasWelcomeMessage: Bool? + let createdAt: Date? + let updatedAt: Date? + let driveId: String? + let driveName: String? + let driveSlug: String? + let hasSystemPrompt: Bool? + let systemPromptPreview: String? + let enabledToolsCount: Int? + + /// Convert to Agent model for UI display + func toAgent() -> Agent { + Agent( + id: "page_\(id)", + type: .pageAI, + title: title ?? "Untitled Agent", + subtitle: driveName.map { "\($0)" }, + icon: "bubble.left.and.text.bubble.right", + driveId: driveId, + driveName: driveName, + pageId: id, + pagePath: nil, + aiProvider: aiProvider, + aiModel: aiModel, + systemPrompt: nil, // Not included in summary + enabledTools: nil // Not included in summary + ) + } +} diff --git a/apps/ios/PageSpace/Core/Models/User.swift b/apps/ios/PageSpace/Core/Models/User.swift index 51d11bfa49..a45e556e3f 100644 --- a/apps/ios/PageSpace/Core/Models/User.swift +++ b/apps/ios/PageSpace/Core/Models/User.swift @@ -90,9 +90,10 @@ struct ProviderStatuses: Codable { let ollama: ProviderStatus? let lmstudio: ProviderStatus? let glm: ProviderStatus? + let minimax: ProviderStatus? enum CodingKeys: String, CodingKey { - case pagespace, openrouter, google, openai, anthropic, xai, ollama, lmstudio, glm + case pagespace, openrouter, google, openai, anthropic, xai, ollama, lmstudio, glm, minimax } } @@ -143,6 +144,8 @@ extension AISettings { return providers.lmstudio?.isConfigured ?? false case "glm": return providers.glm?.isConfigured ?? false + case "minimax": + return providers.minimax?.isConfigured ?? false default: return false } diff --git a/apps/ios/PageSpace/Core/Networking/APIEndpoints.swift b/apps/ios/PageSpace/Core/Networking/APIEndpoints.swift index c3b3204ee5..0d6006ee53 100644 --- a/apps/ios/PageSpace/Core/Networking/APIEndpoints.swift +++ b/apps/ios/PageSpace/Core/Networking/APIEndpoints.swift @@ -62,4 +62,11 @@ enum APIEndpoints { // MARK: - Search static let search = "/api/search" + + // MARK: - Page Agents + static let multiDriveAgents = "/api/ai/page-agents/multi-drive" + + static func driveAgents(driveId: String) -> String { + "/api/drives/\(driveId)/agents" + } } diff --git a/apps/ios/PageSpace/Core/Services/AgentService.swift b/apps/ios/PageSpace/Core/Services/AgentService.swift index 9e55898120..dde602d77a 100644 --- a/apps/ios/PageSpace/Core/Services/AgentService.swift +++ b/apps/ios/PageSpace/Core/Services/AgentService.swift @@ -22,75 +22,56 @@ class AgentService: ObservableObject { error = nil var allAgents: [Agent] = [] - var globalAgent: Agent? - var errorMessages: [String] = [] - // 1. Create Global Assistant agent (no conversation ID needed) - globalAgent = Agent( + // 1. Create Global Assistant agent (always available) + let globalAgent = Agent( id: "global_default", type: .global, title: "Global Assistant", subtitle: "Your personal AI assistant", icon: "brain.head.profile" ) - allAgents.append(globalAgent!) + allAgents.append(globalAgent) print("✅ Global Assistant agent created") - // 2. Load all drives and their AI chat pages + // 2. Load all page agents via dedicated multi-drive endpoint do { - let drives: [Drive] = try await apiClient.request( - endpoint: APIEndpoints.drives, + let response: MultiDriveAgentsResponse = try await apiClient.request( + endpoint: APIEndpoints.multiDriveAgents, method: .GET ) - print("✅ Loaded \(drives.count) drives") - - // For each drive, load pages and filter AI_CHAT pages - for drive in drives { - do { - // Backend returns tree array directly (not wrapped) - let pageTree: [Page] = try await apiClient.request( - endpoint: APIEndpoints.drivePages(driveId: drive.id), - method: .GET - ) - - // Flatten tree structure to get all pages - let allPages = flattenPageTree(pageTree) - print("✅ Drive '\(drive.name)': Loaded \(allPages.count) pages") - - // Filter to AI_CHAT pages only - let aiChatPages = allPages.filter { $0.type == .aiChat } - print(" └─ Found \(aiChatPages.count) AI chat pages") - - // Create agents from AI chat pages - for page in aiChatPages { - let agent = Agent.fromPage(page, drive: drive) - allAgents.append(agent) + print("✅ Loaded agents from \(response.driveCount) drives") + + // Convert AgentSummary to Agent models + if let agentGroups = response.agentsByDrive { + for group in agentGroups { + print(" └─ Drive '\(group.driveName)': \(group.agentCount) agents") + for agentSummary in group.agents { + allAgents.append(agentSummary.toAgent()) } - } catch { - // Log error but continue with other drives - let errorMsg = "Failed to load pages for drive '\(drive.name)': \(error.localizedDescription)" - errorMessages.append(errorMsg) - print("❌ \(errorMsg)") + } + } else if let flatAgents = response.agents { + // Flat list response (groupByDrive=false) + for agentSummary in flatAgents { + allAgents.append(agentSummary.toAgent()) } } + + print("📊 Total page agents loaded: \(response.totalCount)") } catch { - // If we can't load drives, that's a critical error - errorMessages.append("Failed to load drives: \(error.localizedDescription)") - print("❌ Drives error: \(error)") + // Log error but don't fail completely - we still have global agent + let errorMsg = "Failed to load page agents: \(error.localizedDescription)" + self.error = errorMsg + print("❌ \(errorMsg)") } - // Update published property with whatever we managed to load + // Update published property agents = allAgents - print("📊 Total agents loaded: \(allAgents.count)") + print("📊 Total agents available: \(allAgents.count)") // Set default selected agent to global if none selected - if selectedAgent == nil, let defaultAgent = globalAgent { - selectedAgent = defaultAgent - } - - // Set error message if any errors occurred (but don't fail completely) - if !errorMessages.isEmpty { - self.error = errorMessages.joined(separator: "\n") + if selectedAgent == nil { + selectedAgent = globalAgent } isLoading = false diff --git a/apps/web/src/app/api/account/__tests__/delete-route.test.ts b/apps/web/src/app/api/account/__tests__/delete-route.test.ts index cdbea19c2b..371221db66 100644 --- a/apps/web/src/app/api/account/__tests__/delete-route.test.ts +++ b/apps/web/src/app/api/account/__tests__/delete-route.test.ts @@ -1,41 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { NextResponse } from 'next/server'; -import { DELETE } from '../route'; import type { WebAuthResult, AuthError } from '@/lib/auth'; import type { ServiceTokenClaims } from '@pagespace/lib/auth-utils'; -// Mock dependencies - use inline mock factory to avoid hoisting issues -vi.mock('@pagespace/db', () => { - const whereMock = vi.fn().mockResolvedValue([{ count: 0 }]); - const innerJoinMock = vi.fn().mockReturnValue({ where: whereMock }); - const fromMock = vi.fn().mockReturnValue({ where: whereMock, innerJoin: innerJoinMock }); - const selectMock = vi.fn().mockReturnValue({ from: fromMock }); - - const deleteWhereMock = vi.fn().mockResolvedValue(undefined); - const deleteMock = vi.fn().mockReturnValue({ where: deleteWhereMock }); - - return { - db: { - query: { - users: { - findFirst: vi.fn(), - findMany: vi.fn(), - }, - drives: { - findMany: vi.fn(), - }, - }, - select: selectMock, - delete: deleteMock, - }, - users: {}, - drives: {}, - driveMembers: {}, - eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), - sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values, type: 'sql' })), - }; -}); - +// Mock repository seams - the proper architectural boundary vi.mock('@pagespace/lib/server', () => ({ loggers: { auth: { @@ -45,6 +13,16 @@ vi.mock('@pagespace/lib/server', () => ({ debug: vi.fn(), }, }, + accountRepository: { + findById: vi.fn(), + getOwnedDrives: vi.fn(), + getDriveMemberCount: vi.fn(), + deleteDrive: vi.fn(), + deleteUser: vi.fn(), + }, + activityLogRepository: { + anonymizeForUser: vi.fn(), + }, })); vi.mock('@/lib/auth', () => ({ @@ -57,11 +35,19 @@ vi.mock('@pagespace/lib/auth-utils', () => ({ verifyServiceToken: vi.fn(), })); -import { db } from '@pagespace/db'; -import { loggers } from '@pagespace/lib/server'; +import { DELETE } from '../route'; +import { + loggers, + accountRepository, + activityLogRepository, +} from '@pagespace/lib/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { createServiceToken, verifyServiceToken } from '@pagespace/lib/auth-utils'; +// Type the mocked repositories +const mockAccountRepo = vi.mocked(accountRepository); +const mockActivityLogRepo = vi.mocked(activityLogRepository); + // Helper to create mock WebAuthResult const mockWebAuth = (userId: string, tokenVersion = 0): WebAuthResult => ({ userId, @@ -76,43 +62,6 @@ const mockAuthError = (status = 401): AuthError => ({ error: NextResponse.json({ error: 'Unauthorized' }, { status }), }); -// Helper to create mock user matching the actual schema -const mockUser = (overrides: { id: string; email: string; image?: string | null }) => ({ - id: overrides.id, - name: 'Test User', - email: overrides.email, - emailVerified: null as Date | null, - image: overrides.image ?? null, - password: null as string | null, - googleId: null as string | null, - provider: 'email' as const, - role: 'user' as const, - tokenVersion: 0, - currentAiProvider: 'pagespace', - currentAiModel: 'glm-4.5-air', - storageUsedBytes: 0, - activeUploads: 0, - lastStorageCalculated: null as Date | null, - stripeCustomerId: null as string | null, - subscriptionTier: 'free', - tosAcceptedAt: null as Date | null, - createdAt: new Date(), - updatedAt: new Date(), -}); - -// Helper to create mock drive -const mockDrive = (overrides: { id: string; name: string }) => ({ - id: overrides.id, - name: overrides.name, - slug: overrides.id, - ownerId: 'user_123', - createdAt: new Date(), - updatedAt: new Date(), - isTrashed: false, - trashedAt: null, - drivePrompt: null, -}); - // Helper to create mock ServiceTokenClaims const mockServiceClaims = (userId: string): ServiceTokenClaims => ({ sub: userId, @@ -129,298 +78,379 @@ const mockServiceClaims = (userId: string): ServiceTokenClaims => ({ describe('DELETE /api/account', () => { const mockUserId = 'user_123'; const mockUserEmail = 'test@example.com'; - const mockDriveId1 = 'drive_solo'; - const mockDriveId2 = 'drive_multi'; - - // Helper to setup select mock with specific count - const setupSelectMock = (count: number) => { - const whereMock = vi.fn().mockResolvedValue([{ count }]); - const fromMock = vi.fn().mockReturnValue({ where: whereMock }); - vi.mocked(db.select).mockReturnValue({ from: fromMock } as unknown as ReturnType); - }; - - // Helper to setup delete mock with tracking - const setupDeleteMock = () => { - const whereMock = vi.fn().mockResolvedValue(undefined); - vi.mocked(db.delete).mockReturnValue({ where: whereMock } as unknown as ReturnType); - return whereMock; - }; beforeEach(() => { vi.clearAllMocks(); - // Setup default auth success + // Arrange: default successful auth vi.mocked(authenticateRequestWithOptions).mockResolvedValue( mockWebAuth(mockUserId) ); vi.mocked(isAuthError).mockReturnValue(false); - // Setup default user - vi.mocked(db.query.users.findFirst).mockResolvedValue( - mockUser({ id: mockUserId, email: mockUserEmail }) - ); + // Arrange: default user exists + mockAccountRepo.findById.mockResolvedValue({ + id: mockUserId, + email: mockUserEmail, + image: null, + }); - // Setup default: no drives - vi.mocked(db.query.drives.findMany).mockResolvedValue([]); + // Arrange: default no owned drives + mockAccountRepo.getOwnedDrives.mockResolvedValue([]); - // Setup default database operations - setupSelectMock(0); - setupDeleteMock(); + // Arrange: default successful operations + mockAccountRepo.deleteDrive.mockResolvedValue(undefined); + mockAccountRepo.deleteUser.mockResolvedValue(undefined); + mockActivityLogRepo.anonymizeForUser.mockResolvedValue({ success: true }); }); - it('should reject when email confirmation does not match', async () => { - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: 'wrong@example.com' }), - }); - - const response = await DELETE(request); - const body = await response.json(); + describe('email confirmation validation', () => { + it('should reject when email confirmation does not match', async () => { + // Arrange + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: 'wrong@example.com' }), + }); - expect(response.status).toBe(400); - expect(body.error).toBe('Email confirmation does not match your account email'); - }); + // Act + const response = await DELETE(request); + const body = await response.json(); - it('should reject when email confirmation is empty', async () => { - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: '' }), + // Assert + expect(response.status).toBe(400); + expect(body.error).toBe('Email confirmation does not match your account email'); }); - const response = await DELETE(request); - const body = await response.json(); + it('should reject when email confirmation is empty', async () => { + // Arrange + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: '' }), + }); - expect(response.status).toBe(400); - expect(body.error).toBe('Email confirmation does not match your account email'); - }); + // Act + const response = await DELETE(request); + const body = await response.json(); - it('should accept email confirmation with different case', async () => { - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: 'TEST@EXAMPLE.COM' }), + // Assert + expect(response.status).toBe(400); + expect(body.error).toBe('Email confirmation does not match your account email'); }); - const response = await DELETE(request); - - expect(response.status).toBe(200); - }); + it('should accept email confirmation with different case', async () => { + // Arrange + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: 'TEST@EXAMPLE.COM' }), + }); - it('should return 404 when user not found', async () => { - vi.mocked(db.query.users.findFirst).mockResolvedValue(undefined); + // Act + const response = await DELETE(request); - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + // Assert + expect(response.status).toBe(200); }); - const response = await DELETE(request); - const body = await response.json(); - - expect(response.status).toBe(404); - expect(body.error).toBe('User not found'); - }); + it('should trim and lowercase email confirmation', async () => { + // Arrange + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: ' TEST@EXAMPLE.COM ' }), + }); - it('should return 401 when not authenticated', async () => { - vi.mocked(isAuthError).mockReturnValue(true); - vi.mocked(authenticateRequestWithOptions).mockResolvedValue( - mockAuthError(401) - ); + // Act + const response = await DELETE(request); - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + // Assert + expect(response.status).toBe(200); }); - - const response = await DELETE(request); - - expect(response.status).toBe(401); }); - it('should auto-delete solo drives before account deletion', async () => { - // Setup: user owns one solo drive (1 member) - vi.mocked(db.query.drives.findMany).mockResolvedValue([ - mockDrive({ id: mockDriveId1, name: 'Solo Drive' }), - ]); + describe('authentication', () => { + it('should return 401 when not authenticated', async () => { + // Arrange + vi.mocked(isAuthError).mockReturnValue(true); + vi.mocked(authenticateRequestWithOptions).mockResolvedValue( + mockAuthError(401) + ); - // Mock member count query to return 1 (solo) - setupSelectMock(1); + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); - const deleteMock = setupDeleteMock(); + // Act + const response = await DELETE(request); - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + // Assert + expect(response.status).toBe(401); }); - const response = await DELETE(request); + it('should return 404 when user not found', async () => { + // Arrange + mockAccountRepo.findById.mockResolvedValue(null); - expect(response.status).toBe(200); - expect(deleteMock).toHaveBeenCalledTimes(2); // Once for drive, once for user - expect(loggers.auth.info).toHaveBeenCalledWith( - expect.stringContaining('Auto-deleted 1 solo drives') - ); - }); - - it('should block deletion when multi-member drives exist', async () => { - // Setup: user owns one multi-member drive (3 members) - vi.mocked(db.query.drives.findMany).mockResolvedValue([ - mockDrive({ id: mockDriveId2, name: 'Team Drive' }), - ]); + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); - // Mock member count query to return 3 (multi-member) - setupSelectMock(3); + // Act + const response = await DELETE(request); + const body = await response.json(); - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + // Assert + expect(response.status).toBe(404); + expect(body.error).toBe('User not found'); }); - - const response = await DELETE(request); - const body = await response.json(); - - expect(response.status).toBe(400); - expect(body.error).toContain('must transfer ownership or delete'); - expect(body.multiMemberDrives).toContain('Team Drive'); }); - it('should delete user avatar via processor service', async () => { - const mockToken = 'mock-service-token'; - vi.mocked(createServiceToken).mockResolvedValue(mockToken); - vi.mocked(verifyServiceToken).mockResolvedValue(mockServiceClaims(mockUserId)); - - vi.mocked(db.query.users.findFirst).mockResolvedValue( - mockUser({ id: mockUserId, email: mockUserEmail, image: '/avatars/user_123.jpg' }) - ); + describe('drive ownership handling', () => { + it('should auto-delete solo drives before account deletion', async () => { + // Arrange: user owns one solo drive (1 member) + mockAccountRepo.getOwnedDrives.mockResolvedValue([ + { id: 'drive_solo', name: 'Solo Drive' }, + ]); + mockAccountRepo.getDriveMemberCount.mockResolvedValue(1); - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); + + // Act + const response = await DELETE(request); + + // Assert - verify boundary interactions + expect(response.status).toBe(200); + expect(mockAccountRepo.deleteDrive).toHaveBeenCalledWith('drive_solo'); + expect(mockAccountRepo.deleteUser).toHaveBeenCalledWith(mockUserId); + expect(loggers.auth.info).toHaveBeenCalledWith( + expect.stringContaining('Auto-deleted 1 solo drives') + ); }); - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + it('should block deletion when multi-member drives exist', async () => { + // Arrange: user owns one multi-member drive (3 members) + mockAccountRepo.getOwnedDrives.mockResolvedValue([ + { id: 'drive_multi', name: 'Team Drive' }, + ]); + mockAccountRepo.getDriveMemberCount.mockResolvedValue(3); + + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); + + // Act + const response = await DELETE(request); + const body = await response.json(); + + // Assert + expect(response.status).toBe(400); + expect(body.error).toContain('must transfer ownership or delete'); + expect(body.multiMemberDrives).toContain('Team Drive'); + expect(mockAccountRepo.deleteUser).not.toHaveBeenCalled(); }); - await DELETE(request); + it('should handle multiple solo drives correctly', async () => { + // Arrange: user owns 3 solo drives + mockAccountRepo.getOwnedDrives.mockResolvedValue([ + { id: 'drive_1', name: 'Solo 1' }, + { id: 'drive_2', name: 'Solo 2' }, + { id: 'drive_3', name: 'Solo 3' }, + ]); + mockAccountRepo.getDriveMemberCount.mockResolvedValue(1); - expect(global.fetch).toHaveBeenCalledWith( - expect.stringContaining('/api/avatar/'), - expect.objectContaining({ + const request = new Request('https://example.com/api/account', { method: 'DELETE', - headers: expect.objectContaining({ - Authorization: `Bearer ${mockToken}`, - }), - }) - ); + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); + + // Act + const response = await DELETE(request); + + // Assert - verify all drives deleted + expect(response.status).toBe(200); + expect(mockAccountRepo.deleteDrive).toHaveBeenCalledWith('drive_1'); + expect(mockAccountRepo.deleteDrive).toHaveBeenCalledWith('drive_2'); + expect(mockAccountRepo.deleteDrive).toHaveBeenCalledWith('drive_3'); + expect(loggers.auth.info).toHaveBeenCalledWith( + expect.stringContaining('Auto-deleted 3 solo drives') + ); + }); }); - it('should not delete avatar for external URLs', async () => { - vi.mocked(db.query.users.findFirst).mockResolvedValue( - mockUser({ id: mockUserId, email: mockUserEmail, image: 'https://example.com/avatar.jpg' }) - ); - - global.fetch = vi.fn(); - - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + describe('avatar deletion', () => { + it('should delete user avatar via processor service', async () => { + // Arrange + const mockToken = 'mock-service-token'; + vi.mocked(createServiceToken).mockResolvedValue(mockToken); + vi.mocked(verifyServiceToken).mockResolvedValue(mockServiceClaims(mockUserId)); + + mockAccountRepo.findById.mockResolvedValue({ + id: mockUserId, + email: mockUserEmail, + image: '/avatars/user_123.jpg', + }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + }); + + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); + + // Act + await DELETE(request); + + // Assert - verify fetch called with correct payload + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('/api/avatar/'), + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + Authorization: `Bearer ${mockToken}`, + }), + }) + ); }); - await DELETE(request); + it('should not delete avatar for external URLs', async () => { + // Arrange + mockAccountRepo.findById.mockResolvedValue({ + id: mockUserId, + email: mockUserEmail, + image: 'https://example.com/avatar.jpg', + }); - expect(global.fetch).not.toHaveBeenCalled(); - }); + global.fetch = vi.fn(); - it('should continue deletion if avatar deletion fails', async () => { - vi.mocked(db.query.users.findFirst).mockResolvedValue( - mockUser({ id: mockUserId, email: mockUserEmail, image: '/avatars/user_123.jpg' }) - ); + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); - vi.mocked(createServiceToken).mockRejectedValue(new Error('Token creation failed')); + // Act + await DELETE(request); - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + // Assert + expect(global.fetch).not.toHaveBeenCalled(); }); - const response = await DELETE(request); - - // Should still succeed despite avatar deletion failure - expect(response.status).toBe(200); - expect(loggers.auth.error).toHaveBeenCalledWith( - expect.stringContaining('avatar'), - expect.any(Error) - ); - }); + it('should continue deletion if avatar deletion fails', async () => { + // Arrange + mockAccountRepo.findById.mockResolvedValue({ + id: mockUserId, + email: mockUserEmail, + image: '/avatars/user_123.jpg', + }); - it('should delete user from database', async () => { - const deleteMock = setupDeleteMock(); + vi.mocked(createServiceToken).mockRejectedValue(new Error('Token creation failed')); - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); + + // Act + const response = await DELETE(request); + + // Assert - should still succeed despite avatar deletion failure + expect(response.status).toBe(200); + expect(loggers.auth.error).toHaveBeenCalledWith( + expect.stringContaining('avatar'), + expect.any(Error) + ); }); - - const response = await DELETE(request); - - expect(response.status).toBe(200); - expect(deleteMock).toHaveBeenCalled(); - expect(loggers.auth.info).toHaveBeenCalledWith( - expect.stringContaining('User account deleted') - ); }); - it('should handle database errors gracefully', async () => { - const whereMock = vi.fn().mockRejectedValue(new Error('Database connection lost')); - vi.mocked(db.delete).mockReturnValue({ where: whereMock } as unknown as ReturnType); - - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + describe('audit trail compliance (GDPR)', () => { + it('should anonymize activity logs before user deletion', async () => { + // Arrange + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); + + // Act + const response = await DELETE(request); + + // Assert - verify anonymization boundary payload + expect(response.status).toBe(200); + expect(mockActivityLogRepo.anonymizeForUser).toHaveBeenCalledWith( + mockUserId, + expect.stringMatching(/^deleted_user_[a-f0-9]+$/) + ); + expect(loggers.auth.info).toHaveBeenCalledWith( + expect.stringContaining('Anonymized activity logs') + ); }); - const response = await DELETE(request); - const body = await response.json(); + it('should continue with user deletion if anonymization fails', async () => { + // Arrange + mockActivityLogRepo.anonymizeForUser.mockResolvedValue({ + success: false, + error: 'Database connection lost', + }); - expect(response.status).toBe(500); - expect(body.error).toBe('Failed to delete account'); - expect(loggers.auth.error).toHaveBeenCalled(); + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); + + // Act + const response = await DELETE(request); + + // Assert - should still succeed + expect(response.status).toBe(200); + expect(loggers.auth.error).toHaveBeenCalledWith( + expect.stringContaining('anonymize'), + expect.any(Error) + ); + expect(mockAccountRepo.deleteUser).toHaveBeenCalledWith(mockUserId); + }); }); - it('should handle multiple solo drives correctly', async () => { - // Setup: user owns 3 solo drives - vi.mocked(db.query.drives.findMany).mockResolvedValue([ - mockDrive({ id: 'drive_1', name: 'Solo 1' }), - mockDrive({ id: 'drive_2', name: 'Solo 2' }), - mockDrive({ id: 'drive_3', name: 'Solo 3' }), - ]); - - setupSelectMock(1); - const deleteMock = setupDeleteMock(); - - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: mockUserEmail }), + describe('user deletion', () => { + it('should delete user from database', async () => { + // Arrange + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); + + // Act + const response = await DELETE(request); + + // Assert + expect(response.status).toBe(200); + expect(mockAccountRepo.deleteUser).toHaveBeenCalledWith(mockUserId); + expect(loggers.auth.info).toHaveBeenCalledWith( + expect.stringContaining('User account deleted') + ); }); - const response = await DELETE(request); - - expect(response.status).toBe(200); - expect(deleteMock).toHaveBeenCalledTimes(4); // 3 drives + 1 user - expect(loggers.auth.info).toHaveBeenCalledWith( - expect.stringContaining('Auto-deleted 3 solo drives') - ); - }); + it('should handle database errors gracefully', async () => { + // Arrange + mockAccountRepo.deleteUser.mockRejectedValue(new Error('Database connection lost')); - it('should trim and lowercase email confirmation', async () => { - const request = new Request('https://example.com/api/account', { - method: 'DELETE', - body: JSON.stringify({ emailConfirmation: ' TEST@EXAMPLE.COM ' }), - }); + const request = new Request('https://example.com/api/account', { + method: 'DELETE', + body: JSON.stringify({ emailConfirmation: mockUserEmail }), + }); - const response = await DELETE(request); + // Act + const response = await DELETE(request); + const body = await response.json(); - expect(response.status).toBe(200); + // Assert + expect(response.status).toBe(500); + expect(body.error).toBe('Failed to delete account'); + expect(loggers.auth.error).toHaveBeenCalled(); + }); }); }); diff --git a/apps/web/src/app/api/account/route.ts b/apps/web/src/app/api/account/route.ts index 76d8a8ab4d..5a33d780e6 100644 --- a/apps/web/src/app/api/account/route.ts +++ b/apps/web/src/app/api/account/route.ts @@ -1,5 +1,6 @@ -import { users, db, eq, drives, driveMembers, sql } from '@pagespace/db'; -import { loggers } from '@pagespace/lib/server'; +import { users, db, eq } from '@pagespace/db'; +import { createHash } from 'crypto'; +import { loggers, accountRepository, activityLogRepository } from '@pagespace/lib/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { createServiceToken, verifyServiceToken, type ServiceTokenClaims } from '@pagespace/lib/auth-utils'; @@ -104,6 +105,15 @@ export async function PATCH(req: Request) { // Processor service URL const PROCESSOR_URL = process.env.PROCESSOR_URL || 'http://processor:3003'; +/** + * Create an anonymized identifier for GDPR-compliant audit trail preservation. + * Uses a deterministic hash so the same user ID always produces the same anonymized ID. + */ +function createAnonymizedActorEmail(userId: string): string { + const hash = createHash('sha256').update(userId).digest('hex').slice(0, 12); + return `deleted_user_${hash}`; +} + interface AvatarServiceToken { token: string; claims: ServiceTokenClaims; @@ -146,15 +156,8 @@ export async function DELETE(req: Request) { const body = await req.json(); const { emailConfirmation } = body; - // Get user details - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { - id: true, - email: true, - image: true, - }, - }); + // Get user details via repository seam + const user = await accountRepository.findById(userId); if (!user) { return Response.json({ error: 'User not found' }, { status: 404 }); @@ -165,31 +168,18 @@ export async function DELETE(req: Request) { return Response.json({ error: 'Email confirmation does not match your account email' }, { status: 400 }); } - // Check and categorize owned drives - const ownedDrives = await db.query.drives.findMany({ - where: eq(drives.ownerId, userId), - columns: { - id: true, - name: true, - }, - }); + // Check and categorize owned drives via repository seam + const ownedDrives = await accountRepository.getOwnedDrives(userId); if (ownedDrives.length > 0) { const driveIds = ownedDrives.map(d => d.id); - // Count members for each drive + // Count members for each drive via repository seam const memberCounts = await Promise.all( - driveIds.map(async (driveId) => { - const count = await db - .select({ count: sql`count(*)` }) - .from(driveMembers) - .where(eq(driveMembers.driveId, driveId)); - - return { - driveId, - memberCount: Number(count[0]?.count || 0), - }; - }) + driveIds.map(async (driveId) => ({ + driveId, + memberCount: await accountRepository.getDriveMemberCount(driveId), + })) ); // Categorize into solo and multi-member drives @@ -218,10 +208,10 @@ export async function DELETE(req: Request) { ); } - // Auto-delete solo drives + // Auto-delete solo drives via repository seam if (soloDriveIds.length > 0) { for (const driveId of soloDriveIds) { - await db.delete(drives).where(eq(drives.id, driveId)); + await accountRepository.deleteDrive(driveId); } loggers.auth.info(`Auto-deleted ${soloDriveIds.length} solo drives for user ${userId}`); } @@ -244,8 +234,21 @@ export async function DELETE(req: Request) { } } - // Delete the user (CASCADE will handle related records) - await db.delete(users).where(eq(users.id, userId)); + // Anonymize activity logs before user deletion (GDPR compliance + SOX audit trail) + // This preserves the audit trail while removing PII + const anonymizeResult = await activityLogRepository.anonymizeForUser( + userId, + createAnonymizedActorEmail(userId) + ); + if (anonymizeResult.success) { + loggers.auth.info(`Anonymized activity logs for user ${userId}`); + } else { + // Log error but don't fail the deletion - user has right to delete their account + loggers.auth.error('Could not anonymize activity logs during account deletion:', new Error(anonymizeResult.error)); + } + + // Delete the user via repository seam (FK set null will preserve activity logs with userId = null) + await accountRepository.deleteUser(userId); loggers.auth.info(`User account deleted: ${userId}`); diff --git a/apps/web/src/app/api/activities/route.ts b/apps/web/src/app/api/activities/route.ts new file mode 100644 index 0000000000..b1408cc91a --- /dev/null +++ b/apps/web/src/app/api/activities/route.ts @@ -0,0 +1,164 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod/v4'; +import { db, activityLogs, eq, and, desc, count } from '@pagespace/db'; +import { loggers } from '@pagespace/lib/server'; +import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { canUserViewPage, isUserDriveMember } from '@pagespace/lib'; + +const AUTH_OPTIONS = { allow: ['jwt', 'mcp'] as const, requireCSRF: false }; + +// Query parameter schema +const querySchema = z.object({ + context: z.enum(['user', 'drive', 'page']), + driveId: z.string().optional(), + pageId: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +/** + * GET /api/activities + * + * Fetch activity logs based on context: + * - user: User's own activity (for dashboard) + * - drive: All activity within a drive (for drive view) + * - page: All edits to a specific page (for page view) + */ +export async function GET(request: Request) { + const auth = await authenticateRequestWithOptions(request, AUTH_OPTIONS); + if (isAuthError(auth)) { + return auth.error; + } + + const userId = auth.userId; + const { searchParams } = new URL(request.url); + + try { + // Parse and validate query parameters + // Note: searchParams.get() returns null, but Zod's .optional() and .default() + // only work with undefined, so we convert null → undefined + const parseResult = querySchema.safeParse({ + context: searchParams.get('context') || 'user', + driveId: searchParams.get('driveId') ?? undefined, + pageId: searchParams.get('pageId') ?? undefined, + limit: searchParams.get('limit') ?? undefined, + offset: searchParams.get('offset') ?? undefined, + }); + + if (!parseResult.success) { + return NextResponse.json( + { error: parseResult.error.issues.map(i => i.message).join('. ') }, + { status: 400 } + ); + } + + const params = parseResult.data; + + // Build where condition based on context + let whereCondition; + + switch (params.context) { + case 'user': { + // User's own activity (dashboard view) + whereCondition = and( + eq(activityLogs.userId, userId), + eq(activityLogs.isArchived, false) + ); + break; + } + + case 'drive': { + // All activity within a drive + if (!params.driveId) { + return NextResponse.json( + { error: 'driveId is required for drive context' }, + { status: 400 } + ); + } + + // Verify user can view drive + const canViewDrive = await isUserDriveMember(userId, params.driveId); + if (!canViewDrive) { + return NextResponse.json( + { error: 'Unauthorized - you do not have access to this drive' }, + { status: 403 } + ); + } + + whereCondition = and( + eq(activityLogs.driveId, params.driveId), + eq(activityLogs.isArchived, false) + ); + break; + } + + case 'page': { + // All edits to a specific page + if (!params.pageId) { + return NextResponse.json( + { error: 'pageId is required for page context' }, + { status: 400 } + ); + } + + // Verify user can view page + const canViewPage = await canUserViewPage(userId, params.pageId); + if (!canViewPage) { + return NextResponse.json( + { error: 'Unauthorized - you do not have access to this page' }, + { status: 403 } + ); + } + + whereCondition = and( + eq(activityLogs.pageId, params.pageId), + eq(activityLogs.isArchived, false) + ); + break; + } + + default: + return NextResponse.json( + { error: 'Invalid context' }, + { status: 400 } + ); + } + + // Fetch activities with user info + const activities = await db.query.activityLogs.findMany({ + where: whereCondition, + with: { + user: { + columns: { id: true, name: true, email: true, image: true }, + }, + }, + orderBy: [desc(activityLogs.timestamp)], + limit: params.limit, + offset: params.offset, + }); + + // Get total count for pagination + const [countResult] = await db + .select({ total: count() }) + .from(activityLogs) + .where(whereCondition); + + const total = countResult?.total ?? 0; + + return NextResponse.json({ + activities, + pagination: { + total, + limit: params.limit, + offset: params.offset, + hasMore: params.offset + activities.length < total, + }, + }); + } catch (error) { + loggers.api.error('Error fetching activities:', error as Error); + return NextResponse.json( + { error: 'Failed to fetch activities' }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/api/ai/chat/route.ts b/apps/web/src/app/api/ai/chat/route.ts index 13f71b370b..bd65d16e9d 100644 --- a/apps/web/src/app/api/ai/chat/route.ts +++ b/apps/web/src/app/api/ai/chat/route.ts @@ -711,6 +711,9 @@ export async function POST(request: Request) { abortSignal: request.signal, // Enable stop/abort functionality from client experimental_context: { userId, + aiProvider: currentProvider, + aiModel: currentModel, + conversationId, locationContext: pageContext ? { currentPage: { id: pageContext.pageId, @@ -726,7 +729,7 @@ export async function POST(request: Request) { breadcrumbs: pageContext.breadcrumbs, } : undefined, modelCapabilities: getModelCapabilities(currentModel, currentProvider) - }, // Pass userId, location context, and model capabilities to tools + }, // Pass userId, AI context, location context, and model capabilities to tools maxRetries: 20, // Increase from default 2 to 20 for better handling of rate limits onAbort: () => { loggers.ai.info('🛑 AI Chat API: Stream aborted by user', { diff --git a/apps/web/src/app/api/ai/global/[id]/messages/route.ts b/apps/web/src/app/api/ai/global/[id]/messages/route.ts index 3f131aa82c..92a6c485dc 100644 --- a/apps/web/src/app/api/ai/global/[id]/messages/route.ts +++ b/apps/web/src/app/api/ai/global/[id]/messages/route.ts @@ -717,6 +717,9 @@ MENTION PROCESSING: abortSignal: request.signal, // Enable stop/abort functionality from client experimental_context: { userId, + aiProvider: currentProvider, + aiModel: currentModel, + conversationId, locationContext, modelCapabilities: getModelCapabilities(currentModel, currentProvider) }, diff --git a/apps/web/src/app/api/ai/page-agents/consult/route.ts b/apps/web/src/app/api/ai/page-agents/consult/route.ts index b00bfff6bb..efa77146b1 100644 --- a/apps/web/src/app/api/ai/page-agents/consult/route.ts +++ b/apps/web/src/app/api/ai/page-agents/consult/route.ts @@ -241,6 +241,8 @@ export async function POST(request: Request) { // Build execution context for tool execution const executionContext: ToolExecutionContext = { userId, + aiProvider: agent.aiProvider ?? undefined, + aiModel: agent.aiModel ?? undefined, conversationId: `agent-consult-${agentId}-${Date.now()}`, locationContext: { currentPage: { diff --git a/apps/web/src/app/api/pages/[pageId]/__tests__/route.test.ts b/apps/web/src/app/api/pages/[pageId]/__tests__/route.test.ts index 8ba1891143..2990f6f7aa 100644 --- a/apps/web/src/app/api/pages/[pageId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/pages/[pageId]/__tests__/route.test.ts @@ -53,6 +53,11 @@ vi.mock('@pagespace/lib/server', () => ({ debug: vi.fn(), }, }, + getActorInfo: vi.fn().mockResolvedValue({ actorEmail: 'test@example.com', actorDisplayName: 'Test User' }), +})); + +vi.mock('@pagespace/lib', () => ({ + logPageActivity: vi.fn(), })); vi.mock('@pagespace/lib/activity-tracker', () => ({ diff --git a/apps/web/src/app/api/pages/[pageId]/permissions/__tests__/route.test.ts b/apps/web/src/app/api/pages/[pageId]/permissions/__tests__/route.test.ts index 00b76a7ff4..bf39c50e72 100644 --- a/apps/web/src/app/api/pages/[pageId]/permissions/__tests__/route.test.ts +++ b/apps/web/src/app/api/pages/[pageId]/permissions/__tests__/route.test.ts @@ -37,6 +37,7 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@pagespace/lib', () => ({ createPermissionNotification: vi.fn(), + logPermissionActivity: vi.fn(), })); vi.mock('@pagespace/lib/server', () => ({ @@ -48,6 +49,19 @@ vi.mock('@pagespace/lib/server', () => ({ debug: vi.fn(), }, }, + getActorInfo: vi.fn().mockResolvedValue({ actorEmail: 'test@example.com', actorDisplayName: 'Test User' }), +})); + +vi.mock('@pagespace/db', () => ({ + db: { + query: { + pages: { + findFirst: vi.fn().mockResolvedValue({ driveId: 'drive_123', title: 'Test Page' }), + }, + }, + }, + pages: { id: 'id' }, + eq: vi.fn(), })); import { permissionManagementService } from '@/services/api'; diff --git a/apps/web/src/app/api/pages/[pageId]/permissions/route.ts b/apps/web/src/app/api/pages/[pageId]/permissions/route.ts index 8751139d1c..7d3cfd75cb 100644 --- a/apps/web/src/app/api/pages/[pageId]/permissions/route.ts +++ b/apps/web/src/app/api/pages/[pageId]/permissions/route.ts @@ -2,8 +2,10 @@ import { NextResponse } from 'next/server'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { z } from 'zod/v4'; import { createPermissionNotification } from '@pagespace/lib'; -import { loggers } from '@pagespace/lib/server'; +import { loggers, getActorInfo } from '@pagespace/lib/server'; +import { logPermissionActivity } from '@pagespace/lib'; import { permissionManagementService } from '@/services/api'; +import { db, pages, eq } from '@pagespace/db'; const AUTH_OPTIONS_READ = { allow: ['jwt'] as const, requireCSRF: false }; const AUTH_OPTIONS_WRITE = { allow: ['jwt'] as const, requireCSRF: true }; @@ -94,6 +96,27 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId: currentUserId ); + // Log to activity audit trail with actor info + const page = await db.query.pages.findFirst({ + where: eq(pages.id, pageId), + columns: { driveId: true, title: true }, + }); + if (page?.driveId) { + const actorInfo = await getActorInfo(currentUserId); + logPermissionActivity( + currentUserId, + result.isUpdate ? 'permission_update' : 'permission_grant', + { + pageId, + driveId: page.driveId, + targetUserId: userId, + permissions: { canView, canEdit, canShare, canDelete }, + pageTitle: page.title ?? undefined, + }, + actorInfo + ); + } + return NextResponse.json(result.permission, { status: result.isUpdate ? 200 : 201 }); } catch (error) { loggers.api.error('Error creating permission:', error as Error); @@ -139,6 +162,21 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ pageI currentUserId ); + // Log to activity audit trail with actor info + const page = await db.query.pages.findFirst({ + where: eq(pages.id, pageId), + columns: { driveId: true, title: true }, + }); + if (page?.driveId) { + const actorInfo = await getActorInfo(currentUserId); + logPermissionActivity(currentUserId, 'permission_revoke', { + pageId, + driveId: page.driveId, + targetUserId: userId, + pageTitle: page.title ?? undefined, + }, actorInfo); + } + return NextResponse.json({ success: true }); } catch (error) { loggers.api.error('Error deleting permission:', error as Error); diff --git a/apps/web/src/app/api/pages/[pageId]/restore/route.ts b/apps/web/src/app/api/pages/[pageId]/restore/route.ts index 5c27f03d8a..9138bfd00c 100644 --- a/apps/web/src/app/api/pages/[pageId]/restore/route.ts +++ b/apps/web/src/app/api/pages/[pageId]/restore/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server'; import { pages, db, and, eq } from '@pagespace/db'; -import { loggers, pageTreeCache } from '@pagespace/lib/server'; +import { loggers, pageTreeCache, getActorInfo } from '@pagespace/lib/server'; import { trackPageOperation } from '@pagespace/lib/activity-tracker'; +import { logPageActivity } from '@pagespace/lib'; import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; @@ -75,6 +76,16 @@ export async function POST(req: Request, { params }: { params: Promise<{ pageId: pageType: page.type, }); + // Log to activity audit trail with actor info + if (page.drive?.id) { + const actorInfo = await getActorInfo(auth.userId); + logPageActivity(auth.userId, 'restore', { + id: pageId, + title: page.title ?? undefined, + driveId: page.drive.id, + }, actorInfo); + } + return NextResponse.json({ message: 'Page restored successfully.' }); } catch (error) { loggers.api.error('Error restoring page:', error as Error); diff --git a/apps/web/src/app/api/pages/[pageId]/route.ts b/apps/web/src/app/api/pages/[pageId]/route.ts index 4014d634f1..5624655b39 100644 --- a/apps/web/src/app/api/pages/[pageId]/route.ts +++ b/apps/web/src/app/api/pages/[pageId]/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server'; import { z } from "zod/v4"; import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; -import { loggers, agentAwarenessCache, pageTreeCache } from '@pagespace/lib/server'; +import { loggers, agentAwarenessCache, pageTreeCache, getActorInfo } from '@pagespace/lib/server'; import { trackPageOperation } from '@pagespace/lib/activity-tracker'; +import { logPageActivity } from '@pagespace/lib'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { jsonResponse } from '@pagespace/lib/api-utils'; import { pageService } from '@/services/api'; @@ -102,6 +103,18 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ pageId hasTitleUpdate: !!safeBody.title }); + // Log to activity audit trail with actor info + const actorInfo = await getActorInfo(userId); + logPageActivity(userId, 'update', { + id: pageId, + title: result.page.title ?? undefined, + driveId: result.driveId, + content: safeBody.content, // Snapshot for rollback + }, { + ...actorInfo, + updatedFields: result.updatedFields, + }); + return jsonResponse(result.page); } catch (error) { loggers.api.error('Error updating page:', error as Error); @@ -165,6 +178,17 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ pageI pageType: result.pageType }); + // Log to activity audit trail with actor info + const actorInfo = await getActorInfo(userId); + logPageActivity(userId, 'trash', { + id: pageId, + title: result.pageTitle ?? undefined, + driveId: result.driveId, + }, { + ...actorInfo, + metadata: { trashChildren, pageType: result.pageType }, + }); + return NextResponse.json({ message: 'Page moved to trash successfully.' }); } catch (error) { loggers.api.error('Error deleting page:', error as Error); diff --git a/apps/web/src/app/api/pages/__tests__/route.test.ts b/apps/web/src/app/api/pages/__tests__/route.test.ts index 7ac5ecea3a..0ee5b0e542 100644 --- a/apps/web/src/app/api/pages/__tests__/route.test.ts +++ b/apps/web/src/app/api/pages/__tests__/route.test.ts @@ -51,6 +51,11 @@ vi.mock('@pagespace/lib/server', () => ({ debug: vi.fn(), }, }, + getActorInfo: vi.fn().mockResolvedValue({ actorEmail: 'test@example.com', actorDisplayName: 'Test User' }), +})); + +vi.mock('@pagespace/lib', () => ({ + logPageActivity: vi.fn(), })); vi.mock('@pagespace/lib/activity-tracker', () => ({ diff --git a/apps/web/src/app/api/pages/route.ts b/apps/web/src/app/api/pages/route.ts index d579d1eb19..a69bd3ee84 100644 --- a/apps/web/src/app/api/pages/route.ts +++ b/apps/web/src/app/api/pages/route.ts @@ -1,8 +1,9 @@ import { NextResponse } from 'next/server'; import { z } from 'zod/v4'; import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; -import { loggers, agentAwarenessCache, pageTreeCache } from '@pagespace/lib/server'; +import { loggers, agentAwarenessCache, pageTreeCache, getActorInfo } from '@pagespace/lib/server'; import { trackPageOperation } from '@pagespace/lib/activity-tracker'; +import { logPageActivity } from '@pagespace/lib'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { pageService } from '@/services/api'; @@ -84,6 +85,14 @@ export async function POST(request: Request) { parentId: result.page.parentId, }); + // Log to activity audit trail with actor info + const actorInfo = await getActorInfo(userId); + logPageActivity(userId, 'create', { + id: result.page.id, + title: result.page.title ?? undefined, + driveId: result.driveId, + }, actorInfo); + return NextResponse.json(result.page, { status: 201 }); } catch (error) { loggers.api.error('Error creating page:', error as Error); diff --git a/apps/web/src/components/layout/DebugPanel.tsx b/apps/web/src/components/layout/DebugPanel.tsx index ad398a58f0..d46cf79a31 100644 --- a/apps/web/src/components/layout/DebugPanel.tsx +++ b/apps/web/src/components/layout/DebugPanel.tsx @@ -9,13 +9,12 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; -import { - Settings, - Database, - Navigation, - FileText, - Clock, - MemoryStick, +import { + Settings, + Database, + Navigation, + FileText, + Clock, Trash, RefreshCw, Bug, @@ -38,7 +37,6 @@ export function DebugPanel() { const avgLoadTime = getAverageLoadTime(); const clearCache = () => { - layoutStore.clearCache(); localStorage.removeItem('layout-storage'); sessionStorage.clear(); window.location.reload(); @@ -213,22 +211,6 @@ export function DebugPanel() { Layout Store
-
- Active Drive: - - {layoutStore.activeDriveId || 'None'} - -
-
- Active Page: - - {layoutStore.activePageId || 'None'} - -
-
- View Type: - {layoutStore.centerViewType} -
Left Sidebar: @@ -241,6 +223,12 @@ export function DebugPanel() { {layoutStore.rightSidebarOpen ? "Open" : "Closed"}
+
+ Rehydrated: + + {layoutStore.rehydrated ? "Yes" : "No"} + +
@@ -278,36 +266,6 @@ export function DebugPanel() { - {/* Cache State */} -
-

- - View Cache ({layoutStore.viewCache.size}) -

- {layoutStore.viewCache.size > 0 ? ( -
- {Array.from(layoutStore.viewCache.entries()).map(([id, view]) => ( -
-
- {id} - - {view.viewType} - -
-
- - {new Date(view.timestamp).toLocaleTimeString()} -
-
- ))} -
- ) : ( -

No cached views

- )} -
- - - {/* Navigation State */} {navigationContext && (
diff --git a/apps/web/src/components/layout/LayoutErrorBoundary.tsx b/apps/web/src/components/layout/LayoutErrorBoundary.tsx index ef4aff9093..2d249fd7cb 100644 --- a/apps/web/src/components/layout/LayoutErrorBoundary.tsx +++ b/apps/web/src/components/layout/LayoutErrorBoundary.tsx @@ -4,7 +4,6 @@ import React, { Component, ReactNode } from 'react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { AlertTriangle, RefreshCw, Home, Bug } from 'lucide-react'; -import { useLayoutStore } from '@/stores/useLayoutStore'; interface Props { children: ReactNode; @@ -68,13 +67,7 @@ export class LayoutErrorBoundary extends Component { // Clear localStorage that might be corrupted localStorage.removeItem('layout-storage'); sessionStorage.clear(); - - // Clear layout store cache - if (typeof window !== 'undefined') { - const layoutStore = useLayoutStore.getState(); - layoutStore.clearCache(); - } - + console.log('Cleared potentially corrupted state'); } catch (clearError) { console.error('Failed to clear corrupted state:', clearError); diff --git a/apps/web/src/components/layout/NavigationProvider.tsx b/apps/web/src/components/layout/NavigationProvider.tsx index cc945bb895..7c73d29f84 100644 --- a/apps/web/src/components/layout/NavigationProvider.tsx +++ b/apps/web/src/components/layout/NavigationProvider.tsx @@ -2,7 +2,6 @@ import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react'; import Link from 'next/link'; -import { useLayoutStore } from '@/stores/useLayoutStore'; import { LayoutErrorBoundary } from './LayoutErrorBoundary'; import { useUnsavedChanges } from '@/hooks/useUnsavedChanges'; @@ -19,41 +18,19 @@ interface NavigationProviderProps { enableErrorBoundary?: boolean; } -export function NavigationProvider({ - children, - enableErrorBoundary = true +export function NavigationProvider({ + children, + enableErrorBoundary = true }: NavigationProviderProps) { - const layoutStore = useLayoutStore(); useUnsavedChanges(); useEffect(() => { if (process.env.NODE_ENV === 'development') { - console.log('🔧 NavigationProvider mounted'); + console.log('NavigationProvider mounted'); } }, []); - // Cleanup on unmount - useEffect(() => { - return () => { - if (process.env.NODE_ENV === 'development') { - console.log('🧹 NavigationProvider cleaning up...'); - } - - // Get current state for cleanup - const currentState = useLayoutStore.getState(); - - // Save current view before unmounting - if (currentState.activePageId) { - currentState.saveCurrentView(); - } - - // Note: Document saves are now handled by individual page views (DocumentView, SheetView) - // on their own unmount, blur, and keyboard shortcut events - }; - }, []); // No dependencies to prevent loops - // Memoize context value to prevent unnecessary re-renders - // This empty object is stable and doesn't need to change const contextValue: NavigationContextType = useMemo(() => ({}), []); const content = ( @@ -70,9 +47,6 @@ export function NavigationProvider({ if (process.env.NODE_ENV === 'development') { console.error('NavigationProvider error:', error, errorInfo); } - - // Clear potentially corrupted navigation state - layoutStore.clearCache(); }} > {content} diff --git a/apps/web/src/components/layout/middle-content/CenterPanel.tsx b/apps/web/src/components/layout/middle-content/CenterPanel.tsx index 06886d623f..d1972a04b6 100644 --- a/apps/web/src/components/layout/middle-content/CenterPanel.tsx +++ b/apps/web/src/components/layout/middle-content/CenterPanel.tsx @@ -19,7 +19,6 @@ import MCPSettingsView from './page-views/settings/mcp/MCPSettingsView'; import CanvasPageView from './page-views/canvas/CanvasPageView'; import GlobalAssistantView from './page-views/dashboard/GlobalAssistantView'; import { memo, useState, useEffect } from 'react'; -import { useLayoutStore } from '@/stores/useLayoutStore'; import { cn } from '@/lib/utils'; // Memoized page content component to prevent unnecessary re-renders @@ -111,18 +110,18 @@ PageContent.displayName = 'PageContent'; // Optimized header component const OptimizedViewHeader = memo(() => { - const layoutStore = useLayoutStore(); + const params = useParams(); const pathname = usePathname(); - + // Only show header when we have a page or are on special routes - const shouldShowHeader = layoutStore.activePageId || - pathname.endsWith('/settings') || + const shouldShowHeader = params.pageId || + pathname.endsWith('/settings') || pathname.endsWith('/settings/mcp'); - + if (!shouldShowHeader) { return null; } - + return (
diff --git a/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx b/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx index 176d92e020..43f04a93f6 100644 --- a/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/dashboard/GlobalAssistantView.tsx @@ -44,7 +44,7 @@ import { useChat } from '@ai-sdk/react'; import { DefaultChatTransport } from 'ai'; import { usePathname } from 'next/navigation'; import { Button } from '@/components/ui/button'; -import { Settings, Plus, History } from 'lucide-react'; +import { Activity, Plus, History } from 'lucide-react'; import { AiUsageMonitor, AISelector, TasksDropdown } from '@/components/ai/shared'; import { useLayoutStore } from '@/stores/useLayoutStore'; import { useDriveStore } from '@/hooks/useDrive'; @@ -406,9 +406,9 @@ const GlobalAssistantView: React.FC = () => { } }; - const handleOpenSettings = () => { + const handleOpenActivity = () => { if (!rightSidebarOpen) toggleRightSidebar(); - setActiveTab('settings'); + setActiveTab('activity'); }; const handleOpenHistory = () => { @@ -459,7 +459,15 @@ const GlobalAssistantView: React.FC = () => { // Show provider setup if needed if (needsSetup) { - return ; + return ( + { + // Reload settings after API key submission to detect newly configured provider + loadSettings(); + }} + /> + ); } return ( @@ -478,11 +486,11 @@ const GlobalAssistantView: React.FC = () => {