Первоначальная версия VidConf
This commit is contained in:
260
frontend/src/api/admin.ts
Normal file
260
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* API-клиент админ-эндпоинтов (`/api/v1/admin/*`).
|
||||
* Списковые DTO (`AdminConferenceOut`/`AdminUserOut`) отдаются в стандартном
|
||||
* конверте пагинации `items`/`total`.
|
||||
*/
|
||||
import { apiRequest } from '@/api/client'
|
||||
import type { ConferenceRecurrence, ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
|
||||
|
||||
/** Уровень качества AI-обработки (транскрибация + суммаризация). */
|
||||
export type AiLevel = 'min' | 'medium' | 'max'
|
||||
|
||||
/** Доступность одного уровня AI с причиной отказа (если недоступен). */
|
||||
export interface AiLevelStatus {
|
||||
level: AiLevel
|
||||
available: boolean
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
/** Эффективные настройки инстанса. */
|
||||
export interface SettingsOut {
|
||||
chat_enabled: boolean
|
||||
/** Единый переключатель модуля AI (транскрибация + суммаризация). */
|
||||
transcription_enabled: boolean
|
||||
/** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */
|
||||
transcription_queue_served: boolean
|
||||
ai_level: AiLevel
|
||||
ai_levels: AiLevelStatus[]
|
||||
summary_recipients: SummaryRecipientsMode
|
||||
/** IANA-таймзона отображения времени в письмах/.ics (напр. `Europe/Moscow`). */
|
||||
display_timezone: string
|
||||
/** Разрешён ли выбор команды на экране регистрации. */
|
||||
registration_team_choice: boolean
|
||||
/** Включена ли верификация регистрации по домену корпоративной почты. */
|
||||
registration_email_domain_enabled: boolean
|
||||
/** Эталонный домен для верификации (напр. `company.ru`) — `null`, если верификация выключена. */
|
||||
registration_email_domain: string | null
|
||||
}
|
||||
|
||||
/** Тело частичного обновления настроек инстанса — все поля опциональны. */
|
||||
export interface SettingsUpdateIn {
|
||||
chat_enabled?: boolean
|
||||
transcription_enabled?: boolean
|
||||
/** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */
|
||||
ai_level?: AiLevel
|
||||
summary_recipients?: SummaryRecipientsMode
|
||||
display_timezone?: string
|
||||
registration_team_choice?: boolean
|
||||
/** Включение без домена или невалидный домен — backend отвечает 400. */
|
||||
registration_email_domain_enabled?: boolean
|
||||
registration_email_domain?: string | null
|
||||
}
|
||||
|
||||
/** Конференция в ответе админ-списка — `ConferenceOut` + сведения о владельце. */
|
||||
export interface AdminConferenceOut {
|
||||
id: string
|
||||
number: string
|
||||
slug: string
|
||||
title: string | null
|
||||
status: ConferenceStatus
|
||||
is_pinned: boolean
|
||||
is_closed: boolean
|
||||
scheduled_at: string | null
|
||||
duration_minutes: number | null
|
||||
recurrence: ConferenceRecurrence | null
|
||||
next_occurrence: string | null
|
||||
created_at: string
|
||||
summary_recipients: SummaryRecipientsMode | null
|
||||
owner_name: string | null
|
||||
owner_email: string | null
|
||||
}
|
||||
|
||||
/** Параметры выборки списка конференций. */
|
||||
export interface AdminConferenceQuery {
|
||||
status?: ConferenceStatus
|
||||
q?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
/** Тело правки конференции админом — те же поля, что и в пользовательском API. */
|
||||
export interface AdminConferenceUpdatePayload {
|
||||
title?: string
|
||||
scheduled_at?: string
|
||||
duration_minutes?: number
|
||||
is_pinned?: boolean
|
||||
recurrence?: ConferenceRecurrence
|
||||
is_closed?: boolean
|
||||
password?: string
|
||||
summary_recipients?: SummaryRecipientsMode | null
|
||||
}
|
||||
|
||||
/** Пользователь в ответе админ-списка. */
|
||||
export interface AdminUserOut {
|
||||
id: string
|
||||
email: string
|
||||
name_user: string
|
||||
role: 'admin' | 'user'
|
||||
is_blocked: boolean
|
||||
email_verified: boolean
|
||||
created_at: string
|
||||
/** Команда пользователя — `null`, если не назначена. */
|
||||
team_id: string | null
|
||||
/** URL аватара с cache-busting (`?v=`) — `null`, если фото не загружено. */
|
||||
avatar_url: string | null
|
||||
team_name: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Карточка профиля пользователя для админки — тот же
|
||||
* набор полей, что и в списке (backend отдаёт аватар и команду и в списке,
|
||||
* и в карточке).
|
||||
*/
|
||||
export type AdminUserDetailOut = AdminUserOut
|
||||
|
||||
/** Параметры выборки списка пользователей. */
|
||||
export interface AdminUserQuery {
|
||||
q?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
/** Тело правки пользователя — роль, блокировка, ФИО и/или команда, все поля опциональны. */
|
||||
export interface AdminUserUpdatePayload {
|
||||
role?: 'admin' | 'user'
|
||||
is_blocked?: boolean
|
||||
name_user?: string
|
||||
/** `null` — снять пользователя с команды. */
|
||||
team_id?: string | null
|
||||
}
|
||||
|
||||
/** Тело создания пользователя администратором. */
|
||||
export interface AdminUserCreateIn {
|
||||
name_user: string
|
||||
email: string
|
||||
/** Та же политика минимальной длины, что и при самостоятельной регистрации. */
|
||||
password: string
|
||||
team_id?: string | null
|
||||
}
|
||||
|
||||
/** Команда в ответе админ-списка. */
|
||||
export interface TeamOut {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** Параметры выборки списка команд. */
|
||||
export interface AdminTeamQuery {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
/** Стандартный конверт пагинации списковых админ-эндпоинтов. */
|
||||
export interface PagedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
}
|
||||
|
||||
function toQueryString(params: Record<string, string | number | undefined>): string {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== '') search.set(key, String(value))
|
||||
}
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
/** Список конференций для админки — с фильтром по статусу, поиском и пагинацией. */
|
||||
export async function listAdminConferences(query: AdminConferenceQuery = {}): Promise<PagedResult<AdminConferenceOut>> {
|
||||
const qs = toQueryString({ status: query.status, q: query.q, limit: query.limit, offset: query.offset })
|
||||
return apiRequest<PagedResult<AdminConferenceOut>>(`/admin/conferences${qs}`)
|
||||
}
|
||||
|
||||
/** Частично обновить конференцию от имени администратора (реюз `ConferenceService.update`). */
|
||||
export async function updateAdminConference(
|
||||
id: string,
|
||||
payload: AdminConferenceUpdatePayload,
|
||||
): Promise<AdminConferenceOut> {
|
||||
return apiRequest<AdminConferenceOut>(`/admin/conferences/${id}`, { method: 'PATCH', body: payload })
|
||||
}
|
||||
|
||||
/** Удалить конференцию навсегда от имени администратора. 409 — конференция сейчас идёт. */
|
||||
export async function deleteAdminConference(id: string): Promise<void> {
|
||||
await apiRequest(`/admin/conferences/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручная рассылка .ics-приглашений по конференции. Без `emails` — получатели
|
||||
* по умолчанию (владелец + участники прошлых сеансов для закреплённых).
|
||||
* 202 — задача поставлена в очередь, письма отправляются асинхронно.
|
||||
*/
|
||||
export async function sendConferenceInvitations(id: string, emails?: string[]): Promise<void> {
|
||||
await apiRequest(`/admin/conferences/${id}/invitations`, {
|
||||
method: 'POST',
|
||||
body: emails && emails.length > 0 ? { emails } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/** Список пользователей для админки — с поиском и пагинацией. */
|
||||
export async function listAdminUsers(query: AdminUserQuery = {}): Promise<PagedResult<AdminUserOut>> {
|
||||
const qs = toQueryString({ q: query.q, limit: query.limit, offset: query.offset })
|
||||
return apiRequest<PagedResult<AdminUserOut>>(`/admin/users${qs}`)
|
||||
}
|
||||
|
||||
/** Сменить роль и/или блокировку пользователя. 409 — попытка изменить свою учётную запись. */
|
||||
export async function updateAdminUser(id: string, payload: AdminUserUpdatePayload): Promise<AdminUserOut> {
|
||||
return apiRequest<AdminUserOut>(`/admin/users/${id}`, { method: 'PATCH', body: payload })
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать пользователя от имени администратора. 201 — email
|
||||
* считается подтверждённым сразу. 409 — email уже зарегистрирован,
|
||||
* 404 `team_not_found` — указанная команда не существует.
|
||||
*/
|
||||
export async function createAdminUser(payload: AdminUserCreateIn): Promise<AdminUserOut> {
|
||||
return apiRequest<AdminUserOut>('/admin/users', { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
/** Карточка профиля пользователя — открывается по клику на имя в таблице «Пользователи». */
|
||||
export async function getAdminUser(id: string): Promise<AdminUserDetailOut> {
|
||||
return apiRequest<AdminUserDetailOut>(`/admin/users/${id}`)
|
||||
}
|
||||
|
||||
/** Загрузить аватар пользователя от имени администратора. 413/415 — лимиты размера/типа файла. */
|
||||
export async function uploadAdminUserAvatar(id: string, file: File): Promise<AdminUserDetailOut> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return apiRequest<AdminUserDetailOut>(`/admin/users/${id}/avatar`, { method: 'POST', body: form })
|
||||
}
|
||||
|
||||
/** Текущие настройки инстанса. */
|
||||
export async function getAdminSettings(): Promise<SettingsOut> {
|
||||
return apiRequest<SettingsOut>('/admin/settings')
|
||||
}
|
||||
|
||||
/** Частично обновить настройки инстанса. 400 — недоступный уровень AI/невалидная таймзона. */
|
||||
export async function updateAdminSettings(payload: SettingsUpdateIn): Promise<SettingsOut> {
|
||||
return apiRequest<SettingsOut>('/admin/settings', { method: 'PUT', body: payload })
|
||||
}
|
||||
|
||||
/** Список команд для админки — с пагинацией. */
|
||||
export async function listAdminTeams(query: AdminTeamQuery = {}): Promise<PagedResult<TeamOut>> {
|
||||
const qs = toQueryString({ limit: query.limit, offset: query.offset })
|
||||
return apiRequest<PagedResult<TeamOut>>(`/admin/teams${qs}`)
|
||||
}
|
||||
|
||||
/** Создать команду. 409 — имя уже занято. */
|
||||
export async function createAdminTeam(name: string): Promise<TeamOut> {
|
||||
return apiRequest<TeamOut>('/admin/teams', { method: 'POST', body: { name } })
|
||||
}
|
||||
|
||||
/** Переименовать команду. 409 — имя уже занято. */
|
||||
export async function renameAdminTeam(id: string, name: string): Promise<TeamOut> {
|
||||
return apiRequest<TeamOut>(`/admin/teams/${id}`, { method: 'PATCH', body: { name } })
|
||||
}
|
||||
|
||||
/** Удалить команду навсегда. */
|
||||
export async function deleteAdminTeam(id: string): Promise<void> {
|
||||
await apiRequest(`/admin/teams/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
81
frontend/src/api/auth.ts
Normal file
81
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* API-функции аутентификации.
|
||||
*/
|
||||
import { apiRequest } from '@/api/client'
|
||||
|
||||
export interface RegisterPayload {
|
||||
email: string
|
||||
name_user: string
|
||||
password: string
|
||||
/** Выбранная команда — только если выбор команды включён в настройках инстанса. */
|
||||
team_id?: string | null
|
||||
}
|
||||
|
||||
/** Команда, доступная для выбора на экране регистрации. */
|
||||
export interface RegistrationTeamOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Параметры экрана регистрации — зависят от настройки инстанса `registration_team_choice`. */
|
||||
export interface RegistrationOptions {
|
||||
team_choice_enabled: boolean
|
||||
teams: RegistrationTeamOption[]
|
||||
/** Эталонный домен почты при включённой верификации, иначе `null`. */
|
||||
email_domain: string | null
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string
|
||||
email: string
|
||||
name_user: string
|
||||
role: string
|
||||
/** URL загруженного аватара, `null` — показывается заглушка с инициалами (см. `Avatar`). */
|
||||
avatar_url: string | null
|
||||
team_id: string | null
|
||||
team_name: string | null
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
}
|
||||
|
||||
/** Регистрация нового пользователя. 409 — если email уже занят. */
|
||||
export async function register(payload: RegisterPayload): Promise<void> {
|
||||
await apiRequest('/auth/register', { method: 'POST', body: payload, skipAuthRefresh: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Параметры экрана регистрации — публичный эндпоинт, доступен без авторизации.
|
||||
* Если выбор команды отключён в настройках инстанса, `teams` пуст.
|
||||
*/
|
||||
export async function getRegistrationOptions(): Promise<RegistrationOptions> {
|
||||
return apiRequest<RegistrationOptions>('/auth/registration-options', { skipAuthRefresh: true })
|
||||
}
|
||||
|
||||
/** Подтверждение почты по токену из письма. 400 — если ссылка просрочена/недействительна. */
|
||||
export async function verifyEmail(token: string): Promise<void> {
|
||||
await apiRequest('/auth/verify-email', { method: 'POST', body: { token }, skipAuthRefresh: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход по логину/паролю. Backend ждёт OAuth2-form-data (username=email).
|
||||
* Refresh-токен приходит httpOnly cookie — тело ответа содержит только access.
|
||||
*/
|
||||
export async function login(email: string, password: string): Promise<TokenResponse> {
|
||||
const form = new URLSearchParams()
|
||||
form.set('username', email)
|
||||
form.set('password', password)
|
||||
return apiRequest<TokenResponse>('/auth/token', { method: 'POST', body: form, skipAuthRefresh: true })
|
||||
}
|
||||
|
||||
/** Явный выход — инвалидирует refresh-сессию на backend. */
|
||||
export async function logout(): Promise<void> {
|
||||
await apiRequest('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Текущий пользователь по access-токену. */
|
||||
export async function getMe(): Promise<CurrentUser> {
|
||||
return apiRequest<CurrentUser>('/users/me')
|
||||
}
|
||||
143
frontend/src/api/client.ts
Normal file
143
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Базовый API-клиент VidConf.
|
||||
*
|
||||
* - Access-токен подставляется из authStore (память, не localStorage).
|
||||
* - На 401 выполняется один silent-refresh (POST /auth/refresh,
|
||||
* credentials: 'include' — сессия читается из httpOnly-cookie) и повтор
|
||||
* исходного запроса. Если refresh не удался — access-токен сбрасывается и
|
||||
* выполняется редирект на /login.
|
||||
* - Параллельные 401 схлопываются в один refresh-запрос (refreshPromise).
|
||||
*/
|
||||
import { authStore } from '@/auth/authStore'
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
/** Ошибка API с HTTP-статусом и разобранным телом ответа (если это JSON). */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly detail: unknown
|
||||
|
||||
constructor(status: number, detail: unknown, message?: string) {
|
||||
super(message ?? `Ошибка API: ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает машиночитаемый код ошибки из тела ответа FastAPI
|
||||
* (`{"detail": "some_code"}`) — используется для различения причин 403/409
|
||||
* (напр. "password_required" vs "invalid_password").
|
||||
*/
|
||||
export function errorDetail(err: unknown): string | undefined {
|
||||
if (err instanceof ApiError && err.detail && typeof err.detail === 'object' && 'detail' in err.detail) {
|
||||
const value = (err.detail as { detail: unknown }).detail
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: unknown
|
||||
/** Не пытаться делать silent-refresh при 401 (используется самим refresh-запросом). */
|
||||
skipAuthRefresh?: boolean
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
|
||||
/**
|
||||
* Выполняет silent-refresh access-токена через httpOnly refresh-cookie.
|
||||
* Возвращает true при успехе. Параллельные вызовы переиспользуют один запрос.
|
||||
*/
|
||||
export async function refreshAccessToken(): Promise<boolean> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) return false
|
||||
const data = (await response.json()) as { access_token: string }
|
||||
authStore.setAccessToken(data.access_token)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
}
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
/** Редирект на страницу входа при невосстановимой потере сессии. */
|
||||
function redirectToLogin(): void {
|
||||
authStore.setAccessToken(null)
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.assign('/login')
|
||||
}
|
||||
}
|
||||
|
||||
async function parseBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text()
|
||||
if (!text) return null
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Базовый fetch-запрос к API с автоподстановкой access-токена и
|
||||
* silent-refresh на 401.
|
||||
*/
|
||||
export async function apiRequest<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { body, skipAuthRefresh, headers, ...rest } = options
|
||||
|
||||
const doFetch = async (): Promise<Response> => {
|
||||
const finalHeaders = new Headers(headers)
|
||||
const token = authStore.getAccessToken()
|
||||
if (token) finalHeaders.set('Authorization', `Bearer ${token}`)
|
||||
|
||||
let finalBody: BodyInit | undefined
|
||||
if (body !== undefined && !(body instanceof URLSearchParams) && !(body instanceof FormData)) {
|
||||
finalHeaders.set('Content-Type', 'application/json')
|
||||
finalBody = JSON.stringify(body)
|
||||
} else if (body instanceof URLSearchParams) {
|
||||
finalHeaders.set('Content-Type', 'application/x-www-form-urlencoded')
|
||||
finalBody = body
|
||||
} else if (body instanceof FormData) {
|
||||
finalBody = body
|
||||
}
|
||||
|
||||
return fetch(`${API_BASE}${path}`, {
|
||||
...rest,
|
||||
headers: finalHeaders,
|
||||
body: finalBody,
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
let response = await doFetch()
|
||||
|
||||
if (response.status === 401 && !skipAuthRefresh) {
|
||||
const refreshed = await refreshAccessToken()
|
||||
if (refreshed) {
|
||||
response = await doFetch()
|
||||
} else {
|
||||
redirectToLogin()
|
||||
throw new ApiError(401, null, 'Сессия истекла')
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await parseBody(response)
|
||||
throw new ApiError(response.status, detail)
|
||||
}
|
||||
|
||||
if (response.status === 204) return undefined as T
|
||||
return (await parseBody(response)) as T
|
||||
}
|
||||
237
frontend/src/api/conferences.ts
Normal file
237
frontend/src/api/conferences.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* API-функции конференций: предустановленных комнат нет, конференции
|
||||
* создаются динамически.
|
||||
*
|
||||
* Все даты — UTC ISO-8601 со 'Z' (в БД и API только UTC).
|
||||
*/
|
||||
import { apiRequest } from '@/api/client'
|
||||
|
||||
/** Жизненный цикл конференции (см. ADR-001; `draft` не используется). */
|
||||
export type ConferenceStatus = 'scheduled' | 'active' | 'ended'
|
||||
|
||||
/** Тип повторения закреплённой конференции. */
|
||||
export type RecurrenceType = 'weekly' | 'biweekly' | 'monthly' | 'every_n_days'
|
||||
|
||||
/**
|
||||
* Режим рассылки саммари конференции — переопределение дефолта
|
||||
* инстанса (`null` = «по умолчанию», см. `SettingsOut.summary_recipients`
|
||||
* в `src/api/admin.ts`).
|
||||
*/
|
||||
export type SummaryRecipientsMode = 'all' | 'owner'
|
||||
|
||||
/**
|
||||
* Правило повторения закреплённой конференции — форма 1:1 с pydantic-моделью
|
||||
* `backend/services/recurrence.py::RecurrenceRule` (истина о форме — там).
|
||||
* Поля, специфичные для типа (`weekdays` для weekly/biweekly, `day_of_month`
|
||||
* для monthly, `interval_days` для every_n_days), обязательны только для
|
||||
* своего типа — см. валидацию на backend.
|
||||
*/
|
||||
export interface ConferenceRecurrence {
|
||||
type: RecurrenceType
|
||||
/** 0 (понедельник) .. 6 (воскресенье) — обязателен для weekly/biweekly. */
|
||||
weekdays?: number[]
|
||||
/** 1..31 — обязателен для monthly (переносится на последний день короткого месяца). */
|
||||
day_of_month?: number
|
||||
/** >= 1 — обязателен для every_n_days. */
|
||||
interval_days?: number
|
||||
/** Дата первого вхождения серии (YYYY-MM-DD) — якорь отсчёта для biweekly/every_n_days. */
|
||||
anchor_date: string
|
||||
/** Локальное время начала вхождения, формат HH:MM. */
|
||||
time_local: string
|
||||
/** IANA-таймзона (браузера пользователя на момент создания/редактирования). */
|
||||
timezone: string
|
||||
/** Длительность вхождения в минутах — дублирует верхнеуровневое поле конференции (того требует модель RecurrenceRule). */
|
||||
duration_minutes: number
|
||||
}
|
||||
|
||||
/** Данные для входа в LiveKit-комнату конференции. */
|
||||
export interface ConferenceJoinData {
|
||||
livekit_url: string
|
||||
token: string
|
||||
room_name: string
|
||||
conference_id: string
|
||||
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
|
||||
chat_enabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Приглашённый участник конференции — либо
|
||||
* зарегистрированный пользователь (`user_id` заполнен), либо внешний гость
|
||||
* по email (`user_id === null`). Организатор всегда присутствует первым
|
||||
* элементом (`is_organizer: true`), добавляется backend'ом автоматически.
|
||||
*/
|
||||
export interface InviteeOut {
|
||||
user_id: string | null
|
||||
email: string
|
||||
name: string | null
|
||||
avatar_url: string | null
|
||||
is_organizer: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Приглашаемый участник в теле создания/обновления — либо `user_id`
|
||||
* зарегистрированного пользователя, либо `email` внешнего гостя (ровно одно
|
||||
* из полей). Организатора указывать не нужно — backend добавляет его
|
||||
* автоматически и обязательно.
|
||||
*/
|
||||
export type InviteeIn = { user_id: string } | { email: string }
|
||||
|
||||
/** Конференция (ответ API). */
|
||||
export interface ConferenceOut {
|
||||
id: string
|
||||
number: string
|
||||
slug: string
|
||||
title: string | null
|
||||
status: ConferenceStatus
|
||||
is_pinned: boolean
|
||||
is_closed: boolean
|
||||
scheduled_at: string | null
|
||||
duration_minutes: number | null
|
||||
recurrence: ConferenceRecurrence | null
|
||||
next_occurrence: string | null
|
||||
created_at: string
|
||||
/** `null` — используется дефолт инстанса (см. `SettingsOut.summary_recipients`). */
|
||||
summary_recipients: SummaryRecipientsMode | null
|
||||
/** Присутствует только у мгновенной конференции — сразу входим, не дожидаясь отдельного join. */
|
||||
join?: ConferenceJoinData
|
||||
/** Владелец (организатор) конференции. */
|
||||
owner_id: string
|
||||
/** Организатор ли конференции текущий пользователь — относительно него же скрываются кнопки правки/удаления. */
|
||||
is_owner: boolean
|
||||
organizer_name: string | null
|
||||
/** Присутствует ТОЛЬКО в ответе детального эндпоинта (`GET /conferences/{id}`, owner|admin) — списковые эндпоинты (`/conferences/my`, календарь) его не возвращают. */
|
||||
participants?: InviteeOut[]
|
||||
}
|
||||
|
||||
/** Тело запроса на создание конференции. Без `scheduled_at` — мгновенная (ответ сразу содержит `join`). */
|
||||
export interface ConferenceCreatePayload {
|
||||
title?: string
|
||||
/** UTC ISO-8601. */
|
||||
scheduled_at?: string
|
||||
duration_minutes?: number
|
||||
is_pinned?: boolean
|
||||
recurrence?: ConferenceRecurrence
|
||||
is_closed?: boolean
|
||||
password?: string
|
||||
/** `null`/не передано — дефолт инстанса; `'all'`/`'owner'` — явное переопределение. */
|
||||
summary_recipients?: SummaryRecipientsMode | null
|
||||
/** `undefined` — состав не менялся (не отправлять поле); `null`/массив — полная замена состава (кроме организатора, добавляется backend'ом сам). */
|
||||
participants?: InviteeIn[] | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Краткие сведения о конференции по ссылке/номеру — для экрана подключения
|
||||
* (JoinPage). Для `status === 'ended'` (ADR-001) backend отдаёт минимальный
|
||||
* ответ — `is_closed`/`requires_password` отсутствуют (незачем: повторный
|
||||
* вход недоступен независимо от закрытости).
|
||||
*/
|
||||
export interface ConferenceResolveOut {
|
||||
id: string
|
||||
title: string | null
|
||||
status: ConferenceStatus
|
||||
is_closed?: boolean
|
||||
requires_password?: boolean
|
||||
}
|
||||
|
||||
/** Тело входа авторизованного пользователя в конференцию. */
|
||||
export interface ConferenceJoinPayload {
|
||||
password?: string
|
||||
}
|
||||
|
||||
/** Тело гостевого входа — представление обязательно, email факультативен (для рассылки саммари). */
|
||||
export interface ConferenceGuestJoinPayload {
|
||||
display_name: string
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
/** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */
|
||||
export type ConferenceUpdatePayload = Partial<ConferenceCreatePayload>
|
||||
|
||||
/** Одно вхождение (развёрнутое по recurrence или разовое) в календарной выборке. */
|
||||
export interface OccurrenceOut {
|
||||
conference_id: string
|
||||
title: string | null
|
||||
/** UTC ISO-8601. */
|
||||
starts_at: string
|
||||
/** UTC ISO-8601. */
|
||||
ends_at: string
|
||||
number: string
|
||||
slug: string
|
||||
is_pinned: boolean
|
||||
is_closed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать конференцию. Без `scheduled_at` тела — мгновенная: создатель сразу
|
||||
* входит (см. `ConferenceOut.join`).
|
||||
*/
|
||||
export async function createConference(payload: ConferenceCreatePayload = {}): Promise<ConferenceOut> {
|
||||
return apiRequest<ConferenceOut>('/conferences', { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
/**
|
||||
* Резолв конференции по ссылке (slug) или номеру — публичный эндпоинт,
|
||||
* доступен без авторизации (нужен и гостю до входа). 404 — не найдена/недоступна
|
||||
* (в т.ч. незакреплённая, уже завершившаяся).
|
||||
*/
|
||||
export async function resolveConference(query: string): Promise<ConferenceResolveOut> {
|
||||
const params = new URLSearchParams({ q: query })
|
||||
return apiRequest<ConferenceResolveOut>(`/conferences/resolve?${params.toString()}`, {
|
||||
skipAuthRefresh: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Вход авторизованного пользователя в конференцию.
|
||||
* 403 (`password_required`/`invalid_password`), 410 (`conference_ended`).
|
||||
*/
|
||||
export async function joinConference(id: string, payload?: ConferenceJoinPayload): Promise<ConferenceJoinData> {
|
||||
return apiRequest<ConferenceJoinData>(`/conferences/${id}/join`, { method: 'POST', body: payload })
|
||||
}
|
||||
|
||||
/** Гостевой вход без аккаунта — те же коды ошибок, что и `joinConference`. */
|
||||
export async function guestJoinConference(
|
||||
id: string,
|
||||
payload: ConferenceGuestJoinPayload,
|
||||
): Promise<ConferenceJoinData> {
|
||||
return apiRequest<ConferenceJoinData>(`/conferences/${id}/guest-join`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
skipAuthRefresh: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */
|
||||
export async function getMyConferences(): Promise<ConferenceOut[]> {
|
||||
return apiRequest<ConferenceOut[]>('/conferences/my')
|
||||
}
|
||||
|
||||
/**
|
||||
* Вхождения конференций в календарном диапазоне `[from, to]` (обе границы —
|
||||
* UTC ISO, диапазон не длиннее 62 дней — ограничение backend).
|
||||
*/
|
||||
export async function getCalendarOccurrences(fromIso: string, toIso: string): Promise<OccurrenceOut[]> {
|
||||
const params = new URLSearchParams({ from: fromIso, to: toIso })
|
||||
return apiRequest<OccurrenceOut[]>(`/conferences/calendar?${params.toString()}`)
|
||||
}
|
||||
|
||||
/** Частично обновить конференцию (название, время, повторение, закрытость и т.п.). */
|
||||
export async function updateConference(id: string, payload: ConferenceUpdatePayload): Promise<ConferenceOut> {
|
||||
return apiRequest<ConferenceOut>(`/conferences/${id}`, { method: 'PATCH', body: payload })
|
||||
}
|
||||
|
||||
/** Удалить конференцию навсегда. 409 — конференция сейчас идёт (`status: 'active'`). */
|
||||
export async function deleteConference(id: string): Promise<void> {
|
||||
await apiRequest(`/conferences/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Детали одной конференции с полным составом участников (`participants`).
|
||||
* Доступно только владельцу или admin — иначе 403.
|
||||
* Используется ховер-карточкой (§F2) и формой редактирования для подгрузки
|
||||
* состава участников.
|
||||
*/
|
||||
export async function getConference(id: string): Promise<ConferenceOut> {
|
||||
return apiRequest<ConferenceOut>(`/conferences/${id}`)
|
||||
}
|
||||
26
frontend/src/api/health.ts
Normal file
26
frontend/src/api/health.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Клиент публичного эндпоинта здоровья инстанса (`GET /api/health`).
|
||||
*
|
||||
* Живёт вне `/api/v1` (см. `backend/api/health.py` — маршрут зарегистрирован
|
||||
* без версионного префикса) и не требует авторизации, поэтому запрос идёт
|
||||
* напрямую через `fetch`, в обход `apiRequest`/`API_BASE` из `client.ts`.
|
||||
*
|
||||
* Поле `version` (версия инстанса из `VERSION`/`VIDCONF_VERSION`) —
|
||||
* опционально: если backend его не отдаёт, футер админки просто не
|
||||
* показывает версию.
|
||||
*/
|
||||
export interface HealthOut {
|
||||
status: string
|
||||
db: boolean
|
||||
redis: boolean
|
||||
version?: string
|
||||
}
|
||||
|
||||
/** Запросить статус здоровья инстанса. */
|
||||
export async function getHealth(): Promise<HealthOut> {
|
||||
const response = await fetch('/api/health', { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ошибка API: ${response.status}`)
|
||||
}
|
||||
return (await response.json()) as HealthOut
|
||||
}
|
||||
80
frontend/src/api/users.ts
Normal file
80
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* API-функции профиля текущего пользователя и поиска зарегистрированных
|
||||
* пользователей (участники конференций, профиль).
|
||||
*/
|
||||
import { apiRequest } from '@/api/client'
|
||||
import type { CurrentUser } from '@/api/auth'
|
||||
|
||||
/** Элемент результата поиска пользователей (пикер участников конференции) — без email, только для отображения/выбора по `id`. */
|
||||
export interface UserSearchResult {
|
||||
id: string
|
||||
display_name: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
/** Поиск зарегистрированных пользователей по имени/email — для пикера участников конференции. */
|
||||
export async function searchUsers(query: string, limit = 8): Promise<UserSearchResult[]> {
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) })
|
||||
return apiRequest<UserSearchResult[]>(`/users?${params.toString()}`)
|
||||
}
|
||||
|
||||
/** Команда в справочнике команд (страница профиля). */
|
||||
export interface TeamOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Справочник команд для страницы профиля — аутентифицированный эндпоинт,
|
||||
* не зависит от тумблера `registration_team_choice` (в отличие от
|
||||
* `getRegistrationOptions` в `api/auth.ts`, который гасит список команд при
|
||||
* выключенном выборе команды на регистрации).
|
||||
*/
|
||||
export async function listTeams(): Promise<TeamOption[]> {
|
||||
return apiRequest<TeamOption[]>('/teams')
|
||||
}
|
||||
|
||||
/** Тело правки собственного профиля — ФИО и/или команда, email не редактируется. */
|
||||
export interface UpdateMePayload {
|
||||
name_user?: string
|
||||
/** `null` — снять с команды. */
|
||||
team_id?: string | null
|
||||
}
|
||||
|
||||
/** Частично обновить собственный профиль. */
|
||||
export async function updateMe(payload: UpdateMePayload): Promise<CurrentUser> {
|
||||
return apiRequest<CurrentUser>('/users/me', { method: 'PATCH', body: payload })
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить аватар текущего пользователя. 200 — полный обновлённый профиль.
|
||||
* 413 — файл больше 2 МБ, 415 — недопустимый тип (разрешены jpeg/png/webp);
|
||||
* оба кода обрабатываются вызывающей стороной через `ApiError.status`.
|
||||
*/
|
||||
export async function uploadMyAvatar(file: File): Promise<CurrentUser> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return apiRequest<CurrentUser>('/users/me/avatar', { method: 'POST', body: form })
|
||||
}
|
||||
|
||||
/** Удалить аватар текущего пользователя — 204 без тела; вызывающая сторона сама перечитывает профиль (см. `refreshUser`). */
|
||||
export async function deleteMyAvatar(): Promise<void> {
|
||||
await apiRequest('/users/me/avatar', { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Тело смены пароля текущего пользователя. */
|
||||
export interface PasswordChangePayload {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Сменить пароль текущего пользователя. 204 без тела. 400 `invalid_current_password` —
|
||||
* неверный текущий пароль, 422 — новый пароль короче 8 символов.
|
||||
*
|
||||
* Refresh-сессии сознательно не отзываются backend'ом (минимальный дифф —
|
||||
* см. ADR-005, отзыв запланирован вместе со сбросом пароля по email в v0.1.0).
|
||||
*/
|
||||
export async function changePassword(payload: PasswordChangePayload): Promise<void> {
|
||||
await apiRequest('/users/me/password', { method: 'POST', body: payload })
|
||||
}
|
||||
Reference in New Issue
Block a user