/** * 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 /** Одно вхождение (развёрнутое по 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 { return apiRequest('/conferences', { method: 'POST', body: payload }) } /** * Резолв конференции по ссылке (slug) или номеру — публичный эндпоинт, * доступен без авторизации (нужен и гостю до входа). 404 — не найдена/недоступна * (в т.ч. незакреплённая, уже завершившаяся). */ export async function resolveConference(query: string): Promise { const params = new URLSearchParams({ q: query }) return apiRequest(`/conferences/resolve?${params.toString()}`, { skipAuthRefresh: true, }) } /** * Вход авторизованного пользователя в конференцию. * 403 (`password_required`/`invalid_password`), 410 (`conference_ended`). */ export async function joinConference(id: string, payload?: ConferenceJoinPayload): Promise { return apiRequest(`/conferences/${id}/join`, { method: 'POST', body: payload }) } /** Гостевой вход без аккаунта — те же коды ошибок, что и `joinConference`. */ export async function guestJoinConference( id: string, payload: ConferenceGuestJoinPayload, ): Promise { return apiRequest(`/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 { return apiRequest(`/conferences/${conferenceId}/mute-participant`, { method: 'POST', body: { identity, source }, }) } /** Список «моих» конференций — закреплённые (повторяющиеся) и предстоящие разовые владельца. */ export async function getMyConferences(): Promise { return apiRequest('/conferences/my') } /** * Вхождения конференций в календарном диапазоне `[from, to]` (обе границы — * UTC ISO, диапазон не длиннее 62 дней — ограничение backend). */ export async function getCalendarOccurrences(fromIso: string, toIso: string): Promise { const params = new URLSearchParams({ from: fromIso, to: toIso }) return apiRequest(`/conferences/calendar?${params.toString()}`) } /** Частично обновить конференцию (название, время, повторение, закрытость и т.п.). */ export async function updateConference(id: string, payload: ConferenceUpdatePayload): Promise { return apiRequest(`/conferences/${id}`, { method: 'PATCH', body: payload }) } /** Удалить конференцию навсегда. 409 — конференция сейчас идёт (`status: 'active'`). */ export async function deleteConference(id: string): Promise { await apiRequest(`/conferences/${id}`, { method: 'DELETE' }) } /** * Детали одной конференции с полным составом участников (`participants`). * Доступно только владельцу или admin — иначе 403. * Используется ховер-карточкой (§F2) и формой редактирования для подгрузки * состава участников. */ export async function getConference(id: string): Promise { return apiRequest(`/conferences/${id}`) }