Newer
Older
gnexus-tasks / frontend / src / api.ts
// Мини-клиент API. Ошибки авторизации редиректят на вход.

export interface Tag {
  id: number
  name: string
}

export interface Project {
  id: number
  name: string
  relevance_status: string
  priority: number | null
  note: string
}

// Черновое предложение автодетализации (LLM)
export interface AiProposal {
  tags: string[]
  project: string | null
  new_project: boolean
  priority: number | null
}

export interface Attachment {
  id: number
  task_id: number
  original_name: string
  mime: string
  size: number
  created_at: string
}

export interface Task {
  id: number
  title: string
  description: string
  task_type: string
  status: string
  detail_state: string
  parent_task_id: number | null
  project: Project | null
  priority: number | null
  tags: Tag[]
  ai_proposal: AiProposal | null
  created_at: string
  approved_at: string | null
  done_at: string | null
}

export interface TaskUpdateInput {
  title?: string
  description?: string
  project_id?: number | null
  tag_ids?: number[]
  priority?: number | null
  status?: string
}

export class ApiError extends Error {
  status: number
  constructor(status: number, message: string) {
    super(message)
    this.status = status
  }
}

async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
  // Content-Type не задаётся для FormData — браузер проставит boundary сам.
  const headers: Record<string, string> = {}
  if (!(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'
  const res = await fetch(path, { headers, ...options })
  if (res.status === 401) {
    window.location.href = '/auth/login'
    throw new ApiError(401, 'Not authenticated')
  }
  if (!res.ok) {
    const body = await res.json().catch(() => ({ detail: res.statusText }))
    throw new ApiError(res.status, body.detail ?? 'Ошибка запроса')
  }
  return res.json() as Promise<T>
}

export const api = {
  // tasks
  listTasks: (params: Record<string, string | number> = {}) =>
    request<Task[]>('/api/tasks?' + new URLSearchParams(
      Object.entries(params).map(([k, v]) => [k, String(v)]),
    )),
  getTask: (id: number) => request<Task>(`/api/tasks/${id}`),
  createTask: (title: string, description = '') =>
    request<{ id: number }>('/api/tasks', {
      method: 'POST',
      body: JSON.stringify({ title, description }),
    }),
  updateTask: (id: number, patch: TaskUpdateInput) =>
    request<Task>(`/api/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(patch) }),
  approveTask: (id: number, applyProposal = false) =>
    request<Task>(`/api/tasks/${id}/approve`, {
      method: 'POST',
      body: JSON.stringify({ apply_proposal: applyProposal }),
    }),
  redetailTask: (id: number) =>
    request<Task>(`/api/tasks/${id}/redetail`, { method: 'POST' }),
  deleteTask: (id: number) => request<{ ok: boolean }>(`/api/tasks/${id}`, { method: 'DELETE' }),
  // attachments
  listAttachments: (taskId: number) =>
    request<Attachment[]>(`/api/tasks/${taskId}/attachments`),
  uploadAttachments: (taskId: number, files: File[]) => {
    const form = new FormData()
    files.forEach((f) => form.append('files', f))
    return request<Attachment[]>(`/api/tasks/${taskId}/attachments`, {
      method: 'POST',
      body: form,
    })
  },
  deleteAttachment: (id: number) =>
    request<{ ok: boolean }>(`/api/attachments/${id}`, { method: 'DELETE' }),
  // Ссылка на файл вложения (нужна сессионная cookie — просто <img src>)
  attachmentUrl: (id: number) => `/api/attachments/${id}/file`,
  // projects
  listProjects: () => request<Project[]>('/api/projects'),
  createProject: (name: string) =>
    request<Project>('/api/projects', { method: 'POST', body: JSON.stringify({ name }) }),
  // tags
  listTags: () => request<Tag[]>('/api/tags'),
  createTag: (name: string) =>
    request<Tag>('/api/tags', { method: 'POST', body: JSON.stringify({ name }) }),
}