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 = { 'Content-Type': 'application/json', 'X-App': 'client', ...(options.headers as Record) }; // 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 = 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 = { '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 = { '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 = { '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();