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
JavaScript
/**
* 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 };