Files
mkd/services/domaGraphQLClient.ts

627 lines
18 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
/**
* GraphQL клиент для работы с Doma AI API
* Документация: https://developers.doma.ai/ru/docs/api/about
*/
import { settingsService, DomaAISettings } from './settingsService';
import { getAuthToken } from './apiClient';
export interface DomaGraphQLResponse<T> {
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<string> {
// Если параметры не переданы, пытаемся получить из настроек
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<DomaAuthResponse>(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<string> {
// Если параметры не переданы, пытаемся получить из настроек
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<PhoneAuthResponse>(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<DomaTicket[]> {
// Пробуем разные возможные названия типов заявок в 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<DomaTicket[]> {
// Пробуем запрос через 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<T>(
query: string,
variables?: Record<string, any>
): Promise<DomaGraphQLResponse<T>> {
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<string, string>)['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<T>;
}
const result: DomaGraphQLResponse<T> = 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<string, string> = {
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();