Files
vidconf/frontend/src/api/conferences.ts
Max Ronzhin 10a3f8b3b4 feat(room): очередь поднятых рук видна всем + отключаемый модуль
Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь
видит любой участник, но опустить чужую руку по-прежнему может только
организатор (сервер это уже проверял, менял только фронт). Кнопка
«Опустить» показывается у записи, только если это своя рука или
пользователь — организатор.

Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый
в админке (instance_settings.hand_queue, дефолт enabled=true, как у
chat_enabled). Настройка едет участнику в JoinOut ещё до входа в
комнату; выключенный модуль гасит кнопки и на фронте, и на бэке —
raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если
модуль выключен, даже если у клиента на руках старый JoinOut.
2026-08-04 18:27:14 +03:00

278 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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'
/**
* Потолок качества исходящего видео участника (`instance_settings.media_limits`,
* см. `SettingsOut`/`SettingsUpdateIn` в `src/api/admin.ts`). `off` — без
* ограничения. Применяется на клиенте через `publishDefaults`
* (`lib/publishQualityCap.ts`) — режет битрейт верхнего слоя симулкаста, а не
* жёсткое разрешение захвата камеры.
*/
export type PublishQualityCap = 'off' | '180p' | '360p' | '720p'
/**
* Правило повторения закреплённой конференции — форма 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
/** Включён ли модуль «поднятие руки» — при `false` кнопка «Рука» и очередь не рендерятся. */
hand_queue_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */
stage_max_tiles: number
}
/**
* Приглашённый участник конференции — либо
* зарегистрированный пользователь (`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
}
/** Источник трека, который организатор может принудительно выключить (задача B2). */
export type MuteSource = 'microphone' | 'camera'
/** Ответ на принудительный мьют — `false`, если трек и так не был опубликован (нечего было мьютить). */
export interface MuteParticipantResult {
muted: boolean
}
/** Тело частичного обновления конференции — те же поля, что и при создании, все опциональны. */
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,
})
}
/**
* Принудительно выключить микрофон/камеру участника (задача B2) — только
* владелец конференции/администратор, иначе 403 (`not_owner`). 404
* (`participant_not_in_room`) — участника с таким `identity` сейчас нет в
* комнате LiveKit.
*/
export async function muteParticipant(
conferenceId: string,
identity: string,
source: MuteSource,
): Promise<MuteParticipantResult> {
return apiRequest<MuteParticipantResult>(`/conferences/${conferenceId}/mute-participant`, {
method: 'POST',
body: { identity, source },
})
}
/** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */
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}`)
}