/** * GraphQL клиент для работы с Doma AI API * Документация: https://developers.doma.ai/ru/docs/api/about */ import { settingsService, DomaAISettings } from './settingsService'; import { getAuthToken } from './apiClient'; export interface DomaGraphQLResponse { data?: T; errors?: Array<{ message: string; extensions?: any; }>; } export interface DomaAuthResponse { authenticateUserWithPassword: { token: string; item: { id: string; name: string; email?: string; phone?: string; }; }; } export interface DomaTicket { id: string; number: string; status: { id: string; name: string; type: string; }; details?: string; description?: string; createdAt: string; updatedAt: string; deadline?: string; property?: { id: string; address: string; unitName?: string; }; client?: { id: string; name: string; phone?: string; }; assignee?: { id: string; name: string; }; category?: { id: string; name: string; }; } export interface DomaTicketsResponse { objs: DomaTicket[]; meta?: { count: number; }; } class DomaGraphQLClient { private apiUrl: string | null = null; private token: string | null = null; private tokenStorageKey = 'doma_ai_token'; constructor() { // Восстанавливаем токен из localStorage, если есть const storedToken = localStorage.getItem(this.tokenStorageKey); if (storedToken) { this.token = storedToken; } // Настраиваем URL бэкенда, через который проксируем запросы в Doma AI this.updateApiUrl(); } /** * Обновляет URL API до нашего backend-прокси, а не напрямую до Doma AI */ private updateApiUrl(): void { const backendBaseUrl = import.meta.env.VITE_API_BASE_URL; if (backendBaseUrl) { // Все запросы к Doma AI теперь идут через backend-прокси this.apiUrl = `${backendBaseUrl.replace(/\/$/, '')}/doma/graphql`; return; } this.apiUrl = null; console.warn( '[DomaGraphQLClient] VITE_API_BASE_URL не задан. ' + 'Настройте URL бэкенда в переменных окружения фронтенда.' ); } /** * Устанавливает настройки API из настроек интеграций */ updateSettings(): void { this.updateApiUrl(); } /** * Аутентификация пользователя через email и пароль * Если параметры не переданы, пытается получить из настроек */ async authenticate(email?: string, password?: string): Promise { // Если параметры не переданы, пытаемся получить из настроек if (!email || !password) { const settings = settingsService.getDomaAISettings(); if (settings?.email && settings?.password) { email = settings.email; password = settings.password; } else { throw new Error('Учетные данные не указаны. Укажите email и пароль в настройках интеграций.'); } } // Обновляем URL перед авторизацией this.updateApiUrl(); const mutation = ` mutation AuthenticateUser($email: String!, $password: String!) { authenticateUserWithPassword(email: $email, password: $password) { token item { id name email phone } } } `; const response = await this.request(mutation, { email, password, }); if (response.errors) { throw new Error(response.errors[0]?.message || 'Ошибка аутентификации'); } if (response.data?.authenticateUserWithPassword?.token) { this.token = response.data.authenticateUserWithPassword.token; localStorage.setItem(this.tokenStorageKey, this.token); return this.token; } throw new Error('Не удалось получить токен авторизации'); } /** * Аутентификация через телефон и пароль * Если параметры не переданы, пытается получить из настроек */ async authenticateWithPhone(phone?: string, password?: string): Promise { // Если параметры не переданы, пытаемся получить из настроек if (!phone || !password) { const settings = settingsService.getDomaAISettings(); if (settings?.phone && settings?.password) { phone = settings.phone; password = settings.password; } else { throw new Error('Учетные данные не указаны. Укажите телефон и пароль в настройках интеграций.'); } } // Обновляем URL перед авторизацией this.updateApiUrl(); const mutation = ` mutation AuthenticateUserWithPhone($phone: String!, $password: String!) { authenticateUserWithPhoneAndPassword(phone: $phone, password: $password) { token item { id name email phone } } } `; interface PhoneAuthResponse { authenticateUserWithPhoneAndPassword: { token: string; item: { id: string; name: string; email?: string; phone?: string; }; }; } const response = await this.request(mutation, { phone, password, }); if (response.errors) { throw new Error(response.errors[0]?.message || 'Ошибка аутентификации'); } if (response.data?.authenticateUserWithPhoneAndPassword?.token) { this.token = response.data.authenticateUserWithPhoneAndPassword.token; localStorage.setItem(this.tokenStorageKey, this.token); return this.token; } throw new Error('Не удалось получить токен авторизации'); } /** * Проверка текущего пользователя */ async getAuthenticatedUser() { const query = ` query { authenticatedUser { id name email phone type } } `; const response = await this.request(query); return response.data; } /** * Получение списка заявок (tickets) * В Doma AI заявки могут называться Ticket, ServiceRequest или аналогично */ async getTickets(filters?: { status?: string; assignee?: string; property?: string; limit?: number; skip?: number; }): Promise { // Пробуем разные возможные названия типов заявок в Doma AI // В зависимости от вашей схемы это может быть Ticket, ServiceRequest, Request и т.д. const query = ` query GetTickets($where: TicketWhereInput, $first: Int, $skip: Int) { objs: tickets(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) { id number status { id name type } details description createdAt updatedAt deadline property { id address unitName } client { id name phone } assignee { id name } category { id name } } } `; const variables: any = { first: filters?.limit || 100, skip: filters?.skip || 0, }; if (filters) { variables.where = {}; if (filters.status) { variables.where.status = { id: filters.status }; } if (filters.assignee) { variables.where.assignee = { id: filters.assignee }; } if (filters.property) { variables.where.property = { id: filters.property }; } } try { const response = await this.request<{ objs: DomaTicket[] }>(query, variables); if (response.errors) { console.warn('[DomaGraphQLClient] Ошибка при получении заявок:', response.errors); // Пробуем альтернативный запрос return await this.getTicketsAlternative(filters); } return response.data?.objs || []; } catch (error) { console.error('[DomaGraphQLClient] Ошибка при запросе заявок:', error); // Пробуем альтернативный запрос return await this.getTicketsAlternative(filters); } } /** * Альтернативный запрос заявок (если основная схема не работает) */ private async getTicketsAlternative(filters?: any): Promise { // Пробуем запрос через ServiceRequest или другие возможные типы const alternativeQueries = [ // Вариант 1: ServiceRequest ` query GetServiceRequests($where: ServiceRequestWhereInput, $first: Int, $skip: Int) { objs: serviceRequests(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) { id number status { id name type } details description createdAt updatedAt deadline property { id address unitName } client { id name phone } assignee { id name } category { id name } } } `, // Вариант 2: Request ` query GetRequests($where: RequestWhereInput, $first: Int, $skip: Int) { objs: requests(where: $where, first: $first, skip: $skip, orderBy: createdAt_DESC) { id number status { id name type } details description createdAt updatedAt deadline property { id address unitName } client { id name phone } assignee { id name } category { id name } } } `, ]; for (const query of alternativeQueries) { try { const variables: any = { first: filters?.limit || 100, skip: filters?.skip || 0, }; if (filters) { variables.where = {}; if (filters.status) { variables.where.status = { id: filters.status }; } } const response = await this.request<{ objs: DomaTicket[] }>(query, variables); if (response.data?.objs) { return response.data.objs; } } catch (error) { console.warn('[DomaGraphQLClient] Альтернативный запрос не сработал:', error); continue; } } return []; } /** * Базовый метод для выполнения GraphQL запросов */ private async request( query: string, variables?: Record ): Promise> { if (!this.apiUrl) { throw new Error('VITE_DOMA_AI_API_URL не настроен. Укажите URL вашего инстанса Doma AI.'); } const headers: HeadersInit = { 'Content-Type': 'application/json', }; const portalToken = getAuthToken(); if (portalToken) { (headers as Record)['Authorization'] = `Bearer ${portalToken}`; } // Важно: авторизационный токен к Doma AI больше не отправляем из браузера напрямую. // Вместо этого передаём его в config.body на наш backend-прокси. // Читаем актуальные настройки интеграции const settings = settingsService.getDomaAISettings(); const config = { apiUrl: settings?.apiUrl, token: settings?.token || this.token || null, }; try { const bodyPayload = { query, variables: variables || {}, config, }; console.log('[DomaGraphQLClient] Запрос к Doma AI', { url: this.apiUrl, hasToken: !!this.token, // Лучше не логировать полный запрос, чтобы не засорять консоль, // показываем только первые символы queryPreview: query.slice(0, 100), }); const response = await fetch(this.apiUrl, { method: 'POST', headers, body: JSON.stringify(bodyPayload), }); const rawText = await response.text(); console.log('[DomaGraphQLClient] Ответ Doma AI', { status: response.status, ok: response.ok, length: rawText.length, preview: rawText.slice(0, 300), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } if (!rawText) { // Пустой ответ от Doma AI return {} as DomaGraphQLResponse; } const result: DomaGraphQLResponse = JSON.parse(rawText); // Если получили ошибку авторизации, очищаем токен if (result.errors?.some(e => e.message.includes('auth') || e.message.includes('unauthorized'))) { this.token = null; localStorage.removeItem(this.tokenStorageKey); throw new Error('Требуется повторная авторизация'); } return result; } catch (error) { console.error('[DomaGraphQLClient] Ошибка запроса:', error); throw error; } } /** * Установка токена вручную (если токен получен извне) */ setToken(token: string): void { this.token = token; localStorage.setItem(this.tokenStorageKey, token); } /** * Очистка токена (выход) */ clearToken(): void { this.token = null; localStorage.removeItem(this.tokenStorageKey); } /** * Проверка наличия токена */ hasToken(): boolean { return !!this.token; } /** * Обновление заявки (ticket) в Doma AI * @param ticketId - ID заявки в Doma AI (UUID) * @param data - Данные для обновления */ async updateTicket( ticketId: string, data: { status?: { id?: string; type?: string }; statusReason?: string; deferredUntil?: string; lastCommentWithResidentTypeAt?: string; } ): Promise<{ id: string; number: string }> { const mutation = ` mutation UpdateTicket($id: ID!, $data: TicketUpdateInput!) { obj: updateTicket(id: $id, data: $data) { id number status { id name type } updatedAt } } `; const variables: any = { id: ticketId, data: {}, }; // Маппинг локальных статусов в статусы Doma.AI const statusTypeMap: Record = { new: 'new_or_reopened', in_progress: 'processing', deferred: 'deferred', done: 'completed', canceled: 'canceled', }; if (data.status?.type) { const domaStatusType = statusTypeMap[data.status.type] || data.status.type; variables.data.status = { type: domaStatusType }; } else if (data.status?.id) { variables.data.status = { id: data.status.id }; } if (data.statusReason) { variables.data.statusReason = data.statusReason; } if (data.deferredUntil) { variables.data.deferredUntil = data.deferredUntil; } if (data.lastCommentWithResidentTypeAt) { variables.data.lastCommentWithResidentTypeAt = data.lastCommentWithResidentTypeAt; } // Устанавливаем дату обновления статуса variables.data.statusUpdatedAt = new Date().toISOString(); const response = await this.request<{ obj: { id: string; number: string } }>(mutation, variables); if (response.errors) { throw new Error(response.errors[0]?.message || 'Ошибка при обновлении заявки'); } if (!response.data?.obj) { throw new Error('Не удалось обновить заявку'); } return response.data.obj; } } export const domaGraphQLClient = new DomaGraphQLClient();