Files
service_manager/frontend-client/src/lib/stores/tickets.ts
2026-03-17 13:49:23 -06:00

405 lines
12 KiB
TypeScript

import type { Writable } from 'svelte/store';
import { get, writable } from 'svelte/store';
import { auth } from './auth';
interface FastAPIValidationError {
loc: (string | number)[];
msg: string;
type: string;
}
export interface Ticket {
id: string;
title: string;
description: string;
status: 'NEW' | 'IN_PROGRESS' | 'WAITING_CUSTOMER' | 'RESOLVED' | 'CLOSED' | 'REOPENED';
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
category_id: string;
category_name?: string;
client_id: string;
assigned_to_id: string | null;
assigned_to_name?: string;
created_at: string;
updated_at: string;
due_date: string | null;
resolution: string | null;
first_response_at?: string | null;
resolved_at?: string | null;
sla_response_due?: string | null;
sla_resolution_due?: string | null;
created_by?: string;
}
export interface TicketComment {
id: string;
ticket_id: string;
author_id: string;
author_name: string;
author_role: string;
content: string;
is_internal: boolean;
created_at: string;
updated_at: string;
}
export interface TicketAttachment {
id: string;
ticket_id: string;
filename: string;
original_filename: string;
mime_type: string;
size_bytes: number;
uploaded_by_id: string;
uploaded_by_name: string;
uploaded_at: string;
}
export interface TicketIssue {
id: string;
ticket_id: string;
content: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
created_by: string;
created_by_name: string;
tagged_users: { id: string; full_name: string; email: string }[];
attachment_filename: string | null;
created_at: string;
updated_at: string;
}
export interface CreateTicketRequest {
title: string;
description: string;
category_id: string;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
}
export interface TicketsState {
tickets: Ticket[];
currentTicket: Ticket | null;
comments: TicketComment[];
attachments: TicketAttachment[];
issues: TicketIssue[];
isLoading: boolean;
error: string | null;
}
const initialState: TicketsState = {
tickets: [],
currentTicket: null,
comments: [],
attachments: [],
issues: [],
isLoading: false,
error: null
};
async function apiCall(endpoint: string, options: RequestInit = {}) {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
// Verificar que el usuario sigue autenticado
if (!authState.isAuthenticated) throw new Error('Session expired');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-App': 'client',
...(options.headers as Record<string, string>)
};
// Solo agregar Authorization si el token existe en memoria
// Si no está (recarga de página), la cookie HttpOnly lo maneja
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1${endpoint}`, {
...options,
credentials: 'include',
headers
});
if (!response.ok) {
let errorMessage = 'Request failed';
try {
const error = await response.json();
if (error.detail) {
if (Array.isArray(error.detail)) {
errorMessage = error.detail
.map((e: FastAPIValidationError) => `${e.loc.join('.')}: ${e.msg}`)
.join(', ');
} else if (typeof error.detail === 'string') {
errorMessage = error.detail;
} else {
errorMessage = JSON.stringify(error.detail);
}
} else {
errorMessage = JSON.stringify(error);
}
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
return response.json();
}
function createTicketsStore() {
const { subscribe, update }: Writable<TicketsState> = writable(initialState);
return {
subscribe,
loadTickets: async () => {
update(state => ({ ...state, isLoading: true, error: null }));
try {
const raw = await apiCall('/tickets/');
const tickets = raw.map((t: any) => ({ ...t, title: t.subject ?? t.title }));
update(state => ({ ...state, tickets, isLoading: false }));
} catch (error) {
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load tickets'
}));
}
},
loadTicket: async (ticketId: string) => {
update(state => ({ ...state, isLoading: true, error: null }));
try {
const [ticketRaw, comments, attachments, issues] = await Promise.all([
apiCall(`/tickets/${ticketId}`),
apiCall(`/tickets/${ticketId}/comments`),
apiCall(`/tickets/${ticketId}/attachments`),
apiCall(`/tickets/${ticketId}/issues`)
]);
const ticket = { ...ticketRaw, title: ticketRaw.subject ?? ticketRaw.title };
update(state => ({
...state,
currentTicket: ticket,
comments,
attachments,
issues,
isLoading: false
}));
} catch (error) {
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load ticket'
}));
}
},
reloadComments: async (ticketId: string) => {
try {
const comments = await apiCall(`/tickets/${ticketId}/comments`);
update(state => ({ ...state, comments }));
} catch {
// Silent fail en polling
}
},
createIssue: async (ticketId: string, data: {
content: string;
priority: string;
tagged_user_ids: string[];
file?: File | null;
}) => {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
if (!authState.isAuthenticated) throw new Error('Session expired');
const headers: Record<string, string> = { 'X-App': 'client' };
// Solo agregar token si existe — sino la cookie HttpOnly lo maneja
if (authState.token) headers['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) headers['X-Tenant-ID'] = authState.user.tenant_id;
let body: FormData | string;
if (data.file) {
const formData = new FormData();
formData.append('content', data.content);
formData.append('priority', data.priority);
data.tagged_user_ids.forEach(id => formData.append('tagged_user_ids', id));
formData.append('file', data.file);
body = formData;
// NO poner Content-Type — el browser lo agrega con boundary automáticamente
} else {
headers['Content-Type'] = 'application/json';
body = JSON.stringify({
content: data.content,
priority: data.priority,
tagged_user_ids: data.tagged_user_ids
});
}
const response = await fetch(`/api/v1/tickets/${ticketId}/issues`, {
method: 'POST',
credentials: 'include',
headers,
body
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Error creating issue' }));
throw new Error(
typeof error.detail === 'string' ? error.detail : JSON.stringify(error.detail)
);
}
const newIssue = await response.json();
update(state => ({ ...state, issues: [newIssue, ...state.issues] }));
return newIssue;
},
createTicket: async (ticket: CreateTicketRequest) => {
update(state => ({ ...state, isLoading: true, error: null }));
try {
const ticketData = {
subject: ticket.title,
description: ticket.description,
category_id: ticket.category_id,
priority: ticket.priority,
system_id: null
};
const newTicket = await apiCall('/tickets/', {
method: 'POST',
body: JSON.stringify(ticketData)
});
update(state => ({
...state,
tickets: [newTicket, ...state.tickets],
isLoading: false
}));
return newTicket;
} catch (error) {
update(state => ({
...state,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to create ticket'
}));
throw error;
}
},
addComment: async (ticketId: string, content: string) => {
try {
const comment = await apiCall(`/tickets/${ticketId}/comments`, {
method: 'POST',
// is_internal siempre false desde el frontend cliente
// el backend además lo bloquearía si fuera true (fix de seguridad pendiente)
body: JSON.stringify({ content, is_internal: false })
});
update(state => ({ ...state, comments: [...state.comments, comment] }));
return comment;
} catch (error) {
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to add comment'
}));
throw error;
}
},
uploadAttachment: async (ticketId: string, file: File) => {
try {
const formData = new FormData();
formData.append('file', file);
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
const uploadHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) uploadHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) uploadHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(`/api/v1/tickets/${ticketId}/attachments`, {
method: 'POST',
credentials: 'include',
headers: uploadHeaders,
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Upload failed');
}
const result = await response.json();
const attachment = result.data || result;
update(state => ({ ...state, attachments: [...state.attachments, attachment] }));
return attachment;
} catch (error) {
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to upload attachment'
}));
throw error;
}
},
closeTicket: async (ticketId: string, resolution?: string) => {
try {
const updatedTicket = await apiCall(`/tickets/${ticketId}/close`, {
method: 'PATCH',
body: JSON.stringify({ resolution_notes: resolution })
});
update(state => ({
...state,
currentTicket: state.currentTicket?.id === ticketId ? updatedTicket : state.currentTicket,
tickets: state.tickets.map(t => t.id === ticketId ? updatedTicket : t)
}));
return updatedTicket;
} catch (error) {
update(state => ({
...state,
error: error instanceof Error ? error.message : 'Failed to close ticket'
}));
throw error;
}
},
clearError: () => { update(state => ({ ...state, error: null })); },
clearCurrentTicket: () => {
update(state => ({
...state,
currentTicket: null,
comments: [],
attachments: [],
issues: []
}));
},
downloadAttachment: async (ticketId: string, attachmentId: string, filename: string) => {
const authState = get(auth);
if (!authState.user) throw new Error('Not authenticated');
const dlHeaders: Record<string, string> = { 'X-App': 'client' };
if (authState.token) dlHeaders['Authorization'] = `Bearer ${authState.token}`;
if (authState.user.tenant_id) dlHeaders['X-Tenant-ID'] = authState.user.tenant_id;
const response = await fetch(
`/api/v1/tickets/${ticketId}/attachments/${attachmentId}/download`,
{ method: 'GET', credentials: 'include', headers: dlHeaders }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Download failed' }));
throw new Error(error.detail || 'Download failed');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
};
}
export const tickets = createTicketsStore();