Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

260
frontend/src/api/admin.ts Normal file
View 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
View 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
View 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
}

View 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}`)
}

View 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
View 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 })
}