UNPKG

besper-frontend-site-dev-main

Version:

Professional B-esper Frontend Site - Site-wide integration toolkit for full website bot deployment

647 lines (551 loc) 18.6 kB
/** * Support Tickets Service * Implements API and operators for support tickets with foreign key relationships * Uses workspace/account and subscription relationships for authorization */ import tokenAuthService from './tokenAuth.js'; import { getRootApiEndpoint } from './centralizedApi.js'; class SupportTicketsService { constructor() { this.authService = tokenAuthService; this.apiEndpoint = `${getRootApiEndpoint()}/support-tickets`; this.messagesCache = new Map(); this.ticketsCache = new Map(); } /** * Get support tickets for authenticated user * Automatically filters based on workspace/account access in Dataverse */ async getSupportTickets(options = {}) { const defaultOptions = { page: 1, limit: 50, status: 'all', // 'open', 'in_progress', 'resolved', 'closed', 'all' priority: 'all', // 'low', 'medium', 'high', 'urgent', 'all' sortBy: 'created_at', sortOrder: 'desc', }; const params = { ...defaultOptions, ...options }; try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } const queryString = new URLSearchParams(params).toString(); const response = await fetch(`${this.apiEndpoint}?${queryString}`, { method: 'GET', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); // Filter tickets based on workspace/account access const accessibleTickets = await this.filterTicketsByWorkspaceAccess( result.tickets || [] ); return { tickets: accessibleTickets, total: result.total, page: result.page, totalPages: result.totalPages, }; } catch (error) { console.error('Error fetching support tickets:', error); throw error; } } /** * Filter tickets based on user's access to related workspace/account entities * Tickets are shared on workspace level - if user can read workspace/account in Dataverse, they can access ticket */ async filterTicketsByWorkspaceAccess(tickets) { const accessibleTickets = []; for (const ticket of tickets) { try { // Check access to related workspace (primary foreign key) const hasWorkspaceAccess = ticket.workspace_id ? await this.authService.canAccessEntity( 'workspace', ticket.workspace_id, ['read'] ) : false; // Check access to related account (secondary foreign key) const hasAccountAccess = ticket.account_id ? await this.authService.canAccessEntity( 'account', ticket.account_id, ['read'] ) : false; // Check access to related subscription (tertiary foreign key) const hasSubscriptionAccess = ticket.subscription_id ? await this.authService.canAccessEntity( 'subscription', ticket.subscription_id, ['read'] ) : false; // User can access ticket if they have access to any of the related Dataverse entities if (hasWorkspaceAccess || hasAccountAccess || hasSubscriptionAccess) { accessibleTickets.push(ticket); } } catch (error) { console.warn( `Failed to check workspace access for ticket ${ticket.id}:`, error ); } } return accessibleTickets; } /** * Get specific support ticket by ID */ async getSupportTicket(ticketId) { try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } // Check cache first if (this.ticketsCache.has(ticketId)) { return this.ticketsCache.get(ticketId); } const response = await fetch(`${this.apiEndpoint}/${ticketId}`, { method: 'GET', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const ticket = await response.json(); // Verify access to this ticket via workspace/account relationships const hasAccess = await this.verifyTicketAccess(ticket); if (!hasAccess) { throw new Error('Access denied to this support ticket'); } // Cache the ticket this.ticketsCache.set(ticketId, ticket); return ticket; } catch (error) { console.error('Error fetching support ticket:', error); throw error; } } /** * Verify user has access to specific ticket via foreign key relationships */ async verifyTicketAccess(ticket) { try { // Check workspace access if (ticket.workspace_id) { const hasWorkspaceAccess = await this.authService.canAccessEntity( 'workspace', ticket.workspace_id, ['read'] ); if (hasWorkspaceAccess) return true; } // Check account access if (ticket.account_id) { const hasAccountAccess = await this.authService.canAccessEntity( 'account', ticket.account_id, ['read'] ); if (hasAccountAccess) return true; } // Check subscription access if (ticket.subscription_id) { const hasSubscriptionAccess = await this.authService.canAccessEntity( 'subscription', ticket.subscription_id, ['read'] ); if (hasSubscriptionAccess) return true; } return false; } catch (error) { console.error('Error verifying ticket access:', error); return false; } } /** * Create new support ticket with foreign key relationships */ async createSupportTicket(ticketData) { try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } // Verify contact access first const contactId = this.authService.getUserPermission('contactId'); if (!contactId) { throw new Error('Contact ID required for support ticket creation'); } const canAccessContact = await this.authService.canAccessEntity( 'contact', contactId, ['read'] ); if (!canAccessContact) { throw new Error( 'Contact verification failed - cannot create support ticket' ); } // Prepare ticket data with foreign key relationships const ticket = { ...ticketData, contact_id: contactId, workspace_id: ticketData.workspace_id || this.authService.getUserPermission('workspaceId'), account_id: ticketData.account_id || this.authService.getUserPermission('accountId'), subscription_id: ticketData.subscription_id || this.authService.getUserPermission('subscriptionId'), created_at: new Date().toISOString(), updated_at: new Date().toISOString(), status: 'open', created_by: contactId, source: 'customer_portal', }; // Validate that at least one foreign key relationship exists if ( !ticket.workspace_id && !ticket.account_id && !ticket.subscription_id ) { throw new Error( 'At least one foreign key relationship (workspace, account, or subscription) is required' ); } const response = await fetch(this.apiEndpoint, { method: 'POST', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify(ticket), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); // Clear cache to force refresh this.ticketsCache.clear(); return result; } catch (error) { console.error('Error creating support ticket:', error); throw error; } } /** * Update support ticket (limited fields for customers) */ async updateSupportTicket(ticketId, updateData) { try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } // Verify access to this ticket first const ticket = await this.getSupportTicket(ticketId); const hasAccess = await this.verifyTicketAccess(ticket); if (!hasAccess) { throw new Error('Access denied to update this support ticket'); } // Only allow certain fields to be updated by customers const allowedFields = ['title', 'description', 'priority']; const filteredUpdate = {}; for (const field of allowedFields) { if (Object.prototype.hasOwnProperty.call(updateData, field)) { filteredUpdate[field] = updateData[field]; } } filteredUpdate.updated_at = new Date().toISOString(); const response = await fetch(`${this.apiEndpoint}/${ticketId}`, { method: 'PATCH', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify(filteredUpdate), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); // Update cache this.ticketsCache.set(ticketId, result); return result; } catch (error) { console.error('Error updating support ticket:', error); throw error; } } /** * Get messages for a support ticket */ async getTicketMessages(ticketId, options = {}) { const defaultOptions = { page: 1, limit: 50, sortOrder: 'asc', // oldest first for chat-like experience }; const params = { ...defaultOptions, ...options }; try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } // Verify access to ticket first const ticket = await this.getSupportTicket(ticketId); const hasAccess = await this.verifyTicketAccess(ticket); if (!hasAccess) { throw new Error( 'Access denied to view messages for this support ticket' ); } const queryString = new URLSearchParams(params).toString(); const response = await fetch( `${this.apiEndpoint}/${ticketId}/messages?${queryString}`, { method: 'GET', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, } ); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); return { messages: result.messages || [], total: result.total, page: result.page, totalPages: result.totalPages, }; } catch (error) { console.error('Error fetching ticket messages:', error); throw error; } } /** * Add message to support ticket */ async addTicketMessage(ticketId, messageData) { try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } // Verify access to ticket first const ticket = await this.getSupportTicket(ticketId); const hasAccess = await this.verifyTicketAccess(ticket); if (!hasAccess) { throw new Error('Access denied to add message to this support ticket'); } const message = { message: messageData.message, message_type: messageData.message_type || 'customer_reply', created_at: new Date().toISOString(), created_by: this.authService.getUserPermission('contactId'), created_by_name: this.authService.getUserPermission('userName') || this.authService.getUserPermission('name'), attachments: messageData.attachments || [], }; const response = await fetch(`${this.apiEndpoint}/${ticketId}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify(message), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); // Clear messages cache to force refresh this.messagesCache.delete(ticketId); // Update ticket's updated_at timestamp if (this.ticketsCache.has(ticketId)) { const cachedTicket = this.ticketsCache.get(ticketId); cachedTicket.updated_at = new Date().toISOString(); this.ticketsCache.set(ticketId, cachedTicket); } return result; } catch (error) { console.error('Error adding ticket message:', error); throw error; } } /** * Close support ticket (customer can close their own tickets) */ async closeSupportTicket(ticketId, reason = '') { try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } // Verify access to ticket first const ticket = await this.getSupportTicket(ticketId); const hasAccess = await this.verifyTicketAccess(ticket); if (!hasAccess) { throw new Error('Access denied to close this support ticket'); } const closeData = { status: 'closed', closed_at: new Date().toISOString(), closed_by: this.authService.getUserPermission('contactId'), close_reason: reason || 'Closed by customer', updated_at: new Date().toISOString(), }; const response = await fetch(`${this.apiEndpoint}/${ticketId}/close`, { method: 'POST', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify(closeData), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); // Update cache this.ticketsCache.set(ticketId, result); return result; } catch (error) { console.error('Error closing support ticket:', error); throw error; } } /** * Reopen support ticket */ async reopenSupportTicket(ticketId, reason = '') { try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } // Verify access to ticket first const ticket = await this.getSupportTicket(ticketId); const hasAccess = await this.verifyTicketAccess(ticket); if (!hasAccess) { throw new Error('Access denied to reopen this support ticket'); } const reopenData = { status: 'open', reopened_at: new Date().toISOString(), reopened_by: this.authService.getUserPermission('contactId'), reopen_reason: reason || 'Reopened by customer', updated_at: new Date().toISOString(), }; const response = await fetch(`${this.apiEndpoint}/${ticketId}/reopen`, { method: 'POST', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify(reopenData), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); // Update cache this.ticketsCache.set(ticketId, result); return result; } catch (error) { console.error('Error reopening support ticket:', error); throw error; } } /** * Get support ticket statistics for user's accessible tickets */ async getSupportTicketStats() { try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } const response = await fetch(`${this.apiEndpoint}/stats`, { method: 'GET', headers: { Authorization: `Bearer ${this.authService.getToken()}`, }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { console.error('Error fetching support ticket stats:', error); throw error; } } /** * Search support tickets */ async searchSupportTickets(query, options = {}) { try { if (!this.authService.isUserAuthenticated()) { throw new Error('Authentication required'); } const searchParams = { q: query, page: options.page || 1, limit: options.limit || 50, }; const queryString = new URLSearchParams(searchParams).toString(); const response = await fetch( `${this.apiEndpoint}/search?${queryString}`, { method: 'GET', headers: { Authorization: `Bearer ${this.authService.getToken()}`, 'Content-Type': 'application/json', }, } ); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const result = await response.json(); // Filter search results based on workspace access const accessibleTickets = await this.filterTicketsByWorkspaceAccess( result.tickets || [] ); return { tickets: accessibleTickets, total: result.total, page: result.page, totalPages: result.totalPages, }; } catch (error) { console.error('Error searching support tickets:', error); throw error; } } /** * Cleanup resources */ destroy() { this.messagesCache.clear(); this.ticketsCache.clear(); } } // Create singleton instance const supportTicketsService = new SupportTicketsService(); export default supportTicketsService; export { SupportTicketsService };