From dc82f8bb3bdf3ab3d4088e592a30fd63907319e1 Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Thu, 4 Sep 2025 13:00:14 +0200 Subject: [PATCH 01/20] fix: source kind for file processing (#601) --- server/reflector/views/transcripts.py | 3 +- server/reflector/views/transcripts_process.py | 2 +- www/app/(app)/transcripts/new/page.tsx | 13 +++++- www/app/api/schemas.gen.ts | 43 ++++++++++++++++--- www/app/api/types.gen.ts | 10 ++--- 5 files changed, 55 insertions(+), 16 deletions(-) diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index b64ecf11..3f32a9bd 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -96,6 +96,7 @@ class CreateTranscript(BaseModel): name: str source_language: str = Field("en") target_language: str = Field("en") + source_kind: SourceKind | None = None class UpdateTranscript(BaseModel): @@ -213,7 +214,7 @@ async def transcripts_create( user_id = user["sub"] if user else None return await transcripts_controller.add( info.name, - source_kind=SourceKind.LIVE, + source_kind=info.source_kind or SourceKind.LIVE, source_language=info.source_language, target_language=info.target_language, user_id=user_id, diff --git a/server/reflector/views/transcripts_process.py b/server/reflector/views/transcripts_process.py index 0200e7f8..f9295765 100644 --- a/server/reflector/views/transcripts_process.py +++ b/server/reflector/views/transcripts_process.py @@ -34,7 +34,7 @@ async def transcript_process( ) if task_is_scheduled_or_active( - "reflector.pipelines.main_live_pipeline.task_pipeline_process", + "reflector.pipelines.main_file_pipeline.task_pipeline_file_process", transcript_id=transcript_id, ): return ProcessStatus(status="already running") diff --git a/www/app/(app)/transcripts/new/page.tsx b/www/app/(app)/transcripts/new/page.tsx index 698ac47b..2670fd39 100644 --- a/www/app/(app)/transcripts/new/page.tsx +++ b/www/app/(app)/transcripts/new/page.tsx @@ -7,6 +7,7 @@ import About from "../../../(aboutAndPrivacy)/about"; import Privacy from "../../../(aboutAndPrivacy)/privacy"; import { useRouter } from "next/navigation"; import useCreateTranscript from "../createTranscript"; +import { SourceKind } from "../../../api"; import SelectSearch from "react-select-search"; import { supportedLanguages } from "../../../supportedLanguages"; import useSessionStatus from "../../../lib/useSessionStatus"; @@ -61,13 +62,21 @@ const TranscriptCreate = () => { const send = () => { if (loadingRecord || createTranscript.loading || permissionDenied) return; setLoadingRecord(true); - createTranscript.create({ name, target_language: getTargetLanguage() }); + createTranscript.create({ + name, + target_language: getTargetLanguage(), + source_kind: "live" as SourceKind, + }); }; const uploadFile = () => { if (loadingUpload || createTranscript.loading || permissionDenied) return; setLoadingUpload(true); - createTranscript.create({ name, target_language: getTargetLanguage() }); + createTranscript.create({ + name, + target_language: getTargetLanguage(), + source_kind: "file" as SourceKind, + }); }; useEffect(() => { diff --git a/www/app/api/schemas.gen.ts b/www/app/api/schemas.gen.ts index 919040a2..03091a5f 100644 --- a/www/app/api/schemas.gen.ts +++ b/www/app/api/schemas.gen.ts @@ -133,6 +133,16 @@ export const $CreateTranscript = { title: "Target Language", default: "en", }, + source_kind: { + anyOf: [ + { + $ref: "#/components/schemas/SourceKind", + }, + { + type: "null", + }, + ], + }, }, type: "object", required: ["name"], @@ -1031,11 +1041,25 @@ export const $RoomDetails = { title: "Is Shared", }, webhook_url: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], title: "Webhook Url", }, webhook_secret: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + type: "null", + }, + ], title: "Webhook Secret", }, }, @@ -1091,10 +1115,17 @@ export const $SearchResponse = { description: "Total number of search results", }, query: { - type: "string", - minLength: 0, + anyOf: [ + { + type: "string", + minLength: 1, + description: "Search query text", + }, + { + type: "null", + }, + ], title: "Query", - description: "Search query text", }, limit: { type: "integer", @@ -1111,7 +1142,7 @@ export const $SearchResponse = { }, }, type: "object", - required: ["results", "total", "query", "limit", "offset"], + required: ["results", "total", "limit", "offset"], title: "SearchResponse", } as const; diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts index e2e7a020..d724fc98 100644 --- a/www/app/api/types.gen.ts +++ b/www/app/api/types.gen.ts @@ -32,6 +32,7 @@ export type CreateTranscript = { name: string; source_language?: string; target_language?: string; + source_kind?: SourceKind | null; }; export type DeletionStatus = { @@ -191,8 +192,8 @@ export type RoomDetails = { recording_type: string; recording_trigger: string; is_shared: boolean; - webhook_url: string; - webhook_secret: string; + webhook_url: string | null; + webhook_secret: string | null; }; export type RtcOffer = { @@ -206,10 +207,7 @@ export type SearchResponse = { * Total number of search results */ total: number; - /** - * Search query text - */ - query: string; + query?: string | null; /** * Results per page */ From 0663700a615a4af69a03c96c410f049e23ec9443 Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Fri, 5 Sep 2025 10:52:14 +0200 Subject: [PATCH 02/20] fix: align whisper transcriber api with parakeet (#602) * Documents transcriber api * Update whisper transcriber api to match parakeet * Update api transcription spec * Return 400 for unsupported file type * Add params to api spec * Update whisper transcriber implementation to match parakeet --- server/docs/gpu/api-transcription.md | 194 ++++++ .../reflector_transcriber.py | 569 ++++++++++++++++-- server/tests/test_gpu_modal_transcript.py | 3 + 3 files changed, 705 insertions(+), 61 deletions(-) create mode 100644 server/docs/gpu/api-transcription.md diff --git a/server/docs/gpu/api-transcription.md b/server/docs/gpu/api-transcription.md new file mode 100644 index 00000000..7a15d793 --- /dev/null +++ b/server/docs/gpu/api-transcription.md @@ -0,0 +1,194 @@ +## Reflector GPU Transcription API (Specification) + +This document defines the Reflector GPU transcription API that all implementations must adhere to. Current implementations include NVIDIA Parakeet (NeMo) and Whisper (faster-whisper), both deployed on Modal.com. The API surface and response shapes are OpenAI/Whisper-compatible, so clients can switch implementations by changing only the base URL. + +### Base URL and Authentication + +- Example base URLs (Modal web endpoints): + + - Parakeet: `https://--reflector-transcriber-parakeet-web.modal.run` + - Whisper: `https://--reflector-transcriber-web.modal.run` + +- All endpoints are served under `/v1` and require a Bearer token: + +``` +Authorization: Bearer +``` + +Note: To switch implementations, deploy the desired variant and point `TRANSCRIPT_URL` to its base URL. The API is identical. + +### Supported file types + +`mp3, mp4, mpeg, mpga, m4a, wav, webm` + +### Models and languages + +- Parakeet (NVIDIA NeMo): default `nvidia/parakeet-tdt-0.6b-v2` + - Language support: only `en`. Other languages return HTTP 400. +- Whisper (faster-whisper): default `large-v2` (or deployment-specific) + - Language support: multilingual (per Whisper model capabilities). + +Note: The `model` parameter is accepted by all implementations for interface parity. Some backends may treat it as informational. + +### Endpoints + +#### POST /v1/audio/transcriptions + +Transcribe one or more uploaded audio files. + +Request: multipart/form-data + +- `file` (File) — optional. Single file to transcribe. +- `files` (File[]) — optional. One or more files to transcribe. +- `model` (string) — optional. Defaults to the implementation-specific model (see above). +- `language` (string) — optional, defaults to `en`. + - Parakeet: only `en` is accepted; other values return HTTP 400 + - Whisper: model-dependent; typically multilingual +- `batch` (boolean) — optional, defaults to `false`. + +Notes: + +- Provide either `file` or `files`, not both. If neither is provided, HTTP 400. +- `batch` requires `files`; using `batch=true` without `files` returns HTTP 400. +- Response shape for multiple files is the same regardless of `batch`. +- Files sent to this endpoint are processed in a single pass (no VAD/chunking). This is intended for short clips (roughly ≤ 30s; depends on GPU memory/model). For longer audio, prefer `/v1/audio/transcriptions-from-url` which supports VAD-based chunking. + +Responses + +Single file response: + +```json +{ + "text": "transcribed text", + "words": [ + { "word": "hello", "start": 0.0, "end": 0.5 }, + { "word": "world", "start": 0.5, "end": 1.0 } + ], + "filename": "audio.mp3" +} +``` + +Multiple files response: + +```json +{ + "results": [ + {"filename": "a1.mp3", "text": "...", "words": [...]}, + {"filename": "a2.mp3", "text": "...", "words": [...]}] +} +``` + +Notes: + +- Word objects always include keys: `word`, `start`, `end`. +- Some implementations may include a trailing space in `word` to match Whisper tokenization behavior; clients should trim if needed. + +Example curl (single file): + +```bash +curl -X POST \ + -H "Authorization: Bearer $REFLECTOR_GPU_APIKEY" \ + -F "file=@/path/to/audio.mp3" \ + -F "language=en" \ + "$BASE_URL/v1/audio/transcriptions" +``` + +Example curl (multiple files, batch): + +```bash +curl -X POST \ + -H "Authorization: Bearer $REFLECTOR_GPU_APIKEY" \ + -F "files=@/path/a1.mp3" -F "files=@/path/a2.mp3" \ + -F "batch=true" -F "language=en" \ + "$BASE_URL/v1/audio/transcriptions" +``` + +#### POST /v1/audio/transcriptions-from-url + +Transcribe a single remote audio file by URL. + +Request: application/json + +Body parameters: + +- `audio_file_url` (string) — required. URL of the audio file to transcribe. +- `model` (string) — optional. Defaults to the implementation-specific model (see above). +- `language` (string) — optional, defaults to `en`. Parakeet only accepts `en`. +- `timestamp_offset` (number) — optional, defaults to `0.0`. Added to each word's `start`/`end` in the response. + +```json +{ + "audio_file_url": "https://example.com/audio.mp3", + "model": "nvidia/parakeet-tdt-0.6b-v2", + "language": "en", + "timestamp_offset": 0.0 +} +``` + +Response: + +```json +{ + "text": "transcribed text", + "words": [ + { "word": "hello", "start": 10.0, "end": 10.5 }, + { "word": "world", "start": 10.5, "end": 11.0 } + ] +} +``` + +Notes: + +- `timestamp_offset` is added to each word’s `start`/`end` in the response. +- Implementations may perform VAD-based chunking and batching for long-form audio; word timings are adjusted accordingly. + +Example curl: + +```bash +curl -X POST \ + -H "Authorization: Bearer $REFLECTOR_GPU_APIKEY" \ + -H "Content-Type: application/json" \ + -d '{ + "audio_file_url": "https://example.com/audio.mp3", + "language": "en", + "timestamp_offset": 0 + }' \ + "$BASE_URL/v1/audio/transcriptions-from-url" +``` + +### Error handling + +- 400 Bad Request + - Parakeet: `language` other than `en` + - Missing required parameters (`file`/`files` for upload; `audio_file_url` for URL endpoint) + - Unsupported file extension +- 401 Unauthorized + - Missing or invalid Bearer token +- 404 Not Found + - `audio_file_url` does not exist + +### Implementation details + +- GPUs: A10G for small-file/live, L40S for large-file URL transcription (subject to deployment) +- VAD chunking and segment batching; word timings adjusted and overlapping ends constrained +- Pads very short segments (< 0.5s) to avoid model crashes on some backends + +### Server configuration (Reflector API) + +Set the Reflector server to use the Modal backend and point `TRANSCRIPT_URL` to your chosen deployment: + +``` +TRANSCRIPT_BACKEND=modal +TRANSCRIPT_URL=https://--reflector-transcriber-parakeet-web.modal.run +TRANSCRIPT_MODAL_API_KEY= +``` + +### Conformance tests + +Use the pytest-based conformance tests to validate any new implementation (including self-hosted) against this spec: + +``` +TRANSCRIPT_URL=https:// \ +TRANSCRIPT_MODAL_API_KEY=your-api-key \ +uv run -m pytest -m gpu_modal --no-cov server/tests/test_gpu_modal_transcript.py +``` diff --git a/server/gpu/modal_deployments/reflector_transcriber.py b/server/gpu/modal_deployments/reflector_transcriber.py index 4bbbe512..3be25542 100644 --- a/server/gpu/modal_deployments/reflector_transcriber.py +++ b/server/gpu/modal_deployments/reflector_transcriber.py @@ -1,41 +1,78 @@ import os -import tempfile +import sys import threading +import uuid +from typing import Generator, Mapping, NamedTuple, NewType, TypedDict +from urllib.parse import urlparse import modal -from pydantic import BaseModel - -MODELS_DIR = "/models" MODEL_NAME = "large-v2" MODEL_COMPUTE_TYPE: str = "float16" MODEL_NUM_WORKERS: int = 1 - MINUTES = 60 # seconds +SAMPLERATE = 16000 +UPLOADS_PATH = "/uploads" +CACHE_PATH = "/models" +SUPPORTED_FILE_EXTENSIONS = ["mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"] +VAD_CONFIG = { + "batch_max_duration": 30.0, + "silence_padding": 0.5, + "window_size": 512, +} -volume = modal.Volume.from_name("models", create_if_missing=True) + +WhisperUniqFilename = NewType("WhisperUniqFilename", str) +AudioFileExtension = NewType("AudioFileExtension", str) app = modal.App("reflector-transcriber") +model_cache = modal.Volume.from_name("models", create_if_missing=True) +upload_volume = modal.Volume.from_name("whisper-uploads", create_if_missing=True) + + +class TimeSegment(NamedTuple): + """Represents a time segment with start and end times.""" + + start: float + end: float + + +class AudioSegment(NamedTuple): + """Represents an audio segment with timing and audio data.""" + + start: float + end: float + audio: any + + +class TranscriptResult(NamedTuple): + """Represents a transcription result with text and word timings.""" + + text: str + words: list["WordTiming"] + + +class WordTiming(TypedDict): + """Represents a word with its timing information.""" + + word: str + start: float + end: float + def download_model(): from faster_whisper import download_model - volume.reload() + model_cache.reload() - download_model(MODEL_NAME, cache_dir=MODELS_DIR) + download_model(MODEL_NAME, cache_dir=CACHE_PATH) - volume.commit() + model_cache.commit() image = ( modal.Image.debian_slim(python_version="3.12") - .pip_install( - "huggingface_hub==0.27.1", - "hf-transfer==0.1.9", - "torch==2.5.1", - "faster-whisper==1.1.1", - ) .env( { "HF_HUB_ENABLE_HF_TRANSFER": "1", @@ -45,19 +82,98 @@ image = ( ), } ) - .run_function(download_model, volumes={MODELS_DIR: volume}) + .apt_install("ffmpeg") + .pip_install( + "huggingface_hub==0.27.1", + "hf-transfer==0.1.9", + "torch==2.5.1", + "faster-whisper==1.1.1", + "fastapi==0.115.12", + "requests", + "librosa==0.10.1", + "numpy<2", + "silero-vad==5.1.0", + ) + .run_function(download_model, volumes={CACHE_PATH: model_cache}) ) +def detect_audio_format(url: str, headers: Mapping[str, str]) -> AudioFileExtension: + parsed_url = urlparse(url) + url_path = parsed_url.path + + for ext in SUPPORTED_FILE_EXTENSIONS: + if url_path.lower().endswith(f".{ext}"): + return AudioFileExtension(ext) + + content_type = headers.get("content-type", "").lower() + if "audio/mpeg" in content_type or "audio/mp3" in content_type: + return AudioFileExtension("mp3") + if "audio/wav" in content_type: + return AudioFileExtension("wav") + if "audio/mp4" in content_type: + return AudioFileExtension("mp4") + + raise ValueError( + f"Unsupported audio format for URL: {url}. " + f"Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}" + ) + + +def download_audio_to_volume( + audio_file_url: str, +) -> tuple[WhisperUniqFilename, AudioFileExtension]: + import requests + from fastapi import HTTPException + + response = requests.head(audio_file_url, allow_redirects=True) + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Audio file not found") + + response = requests.get(audio_file_url, allow_redirects=True) + response.raise_for_status() + + audio_suffix = detect_audio_format(audio_file_url, response.headers) + unique_filename = WhisperUniqFilename(f"{uuid.uuid4()}.{audio_suffix}") + file_path = f"{UPLOADS_PATH}/{unique_filename}" + + with open(file_path, "wb") as f: + f.write(response.content) + + upload_volume.commit() + return unique_filename, audio_suffix + + +def pad_audio(audio_array, sample_rate: int = SAMPLERATE): + """Add 0.5s of silence if audio is shorter than the silence_padding window. + + Whisper does not require this strictly, but aligning behavior with Parakeet + avoids edge-case crashes on extremely short inputs and makes comparisons easier. + """ + import numpy as np + + audio_duration = len(audio_array) / sample_rate + if audio_duration < VAD_CONFIG["silence_padding"]: + silence_samples = int(sample_rate * VAD_CONFIG["silence_padding"]) + silence = np.zeros(silence_samples, dtype=np.float32) + return np.concatenate([audio_array, silence]) + return audio_array + + @app.cls( gpu="A10G", timeout=5 * MINUTES, scaledown_window=5 * MINUTES, - allow_concurrent_inputs=6, image=image, - volumes={MODELS_DIR: volume}, + volumes={CACHE_PATH: model_cache, UPLOADS_PATH: upload_volume}, ) -class Transcriber: +@modal.concurrent(max_inputs=10) +class TranscriberWhisperLive: + """Live transcriber class for small audio segments (A10G). + + Mirrors the Parakeet live class API but uses Faster-Whisper under the hood. + """ + @modal.enter() def enter(self): import faster_whisper @@ -71,23 +187,200 @@ class Transcriber: device=self.device, compute_type=MODEL_COMPUTE_TYPE, num_workers=MODEL_NUM_WORKERS, - download_root=MODELS_DIR, + download_root=CACHE_PATH, local_files_only=True, ) + print(f"Model is on device: {self.device}") @modal.method() def transcribe_segment( self, - audio_data: str, - audio_suffix: str, - language: str, + filename: str, + language: str = "en", ): - with tempfile.NamedTemporaryFile("wb+", suffix=f".{audio_suffix}") as fp: - fp.write(audio_data) + """Transcribe a single uploaded audio file by filename.""" + upload_volume.reload() + + file_path = f"{UPLOADS_PATH}/{filename}" + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + with self.lock: + with NoStdStreams(): + segments, _ = self.model.transcribe( + file_path, + language=language, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": 500}, + ) + + segments = list(segments) + text = "".join(segment.text for segment in segments).strip() + words = [ + { + "word": word.word, + "start": round(float(word.start), 2), + "end": round(float(word.end), 2), + } + for segment in segments + for word in segment.words + ] + + return {"text": text, "words": words} + + @modal.method() + def transcribe_batch( + self, + filenames: list[str], + language: str = "en", + ): + """Transcribe multiple uploaded audio files and return per-file results.""" + upload_volume.reload() + + results = [] + for filename in filenames: + file_path = f"{UPLOADS_PATH}/{filename}" + if not os.path.exists(file_path): + raise FileNotFoundError(f"Batch file not found: {file_path}") + + with self.lock: + with NoStdStreams(): + segments, _ = self.model.transcribe( + file_path, + language=language, + beam_size=5, + word_timestamps=True, + vad_filter=True, + vad_parameters={"min_silence_duration_ms": 500}, + ) + + segments = list(segments) + text = "".join(seg.text for seg in segments).strip() + words = [ + { + "word": w.word, + "start": round(float(w.start), 2), + "end": round(float(w.end), 2), + } + for seg in segments + for w in seg.words + ] + + results.append( + { + "filename": filename, + "text": text, + "words": words, + } + ) + + return results + + +@app.cls( + gpu="L40S", + timeout=15 * MINUTES, + image=image, + volumes={CACHE_PATH: model_cache, UPLOADS_PATH: upload_volume}, +) +class TranscriberWhisperFile: + """File transcriber for larger/longer audio, using VAD-driven batching (L40S).""" + + @modal.enter() + def enter(self): + import faster_whisper + import torch + from silero_vad import load_silero_vad + + self.lock = threading.Lock() + self.use_gpu = torch.cuda.is_available() + self.device = "cuda" if self.use_gpu else "cpu" + self.model = faster_whisper.WhisperModel( + MODEL_NAME, + device=self.device, + compute_type=MODEL_COMPUTE_TYPE, + num_workers=MODEL_NUM_WORKERS, + download_root=CACHE_PATH, + local_files_only=True, + ) + self.vad_model = load_silero_vad(onnx=False) + + @modal.method() + def transcribe_segment( + self, filename: str, timestamp_offset: float = 0.0, language: str = "en" + ): + import librosa + import numpy as np + from silero_vad import VADIterator + + def vad_segments( + audio_array, + sample_rate: int = SAMPLERATE, + window_size: int = VAD_CONFIG["window_size"], + ) -> Generator[TimeSegment, None, None]: + """Generate speech segments as TimeSegment using Silero VAD.""" + iterator = VADIterator(self.vad_model, sampling_rate=sample_rate) + start = None + for i in range(0, len(audio_array), window_size): + chunk = audio_array[i : i + window_size] + if len(chunk) < window_size: + chunk = np.pad( + chunk, (0, window_size - len(chunk)), mode="constant" + ) + speech = iterator(chunk) + if not speech: + continue + if "start" in speech: + start = speech["start"] + continue + if "end" in speech and start is not None: + end = speech["end"] + yield TimeSegment( + start / float(SAMPLERATE), end / float(SAMPLERATE) + ) + start = None + iterator.reset_states() + + upload_volume.reload() + file_path = f"{UPLOADS_PATH}/{filename}" + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + audio_array, _sr = librosa.load(file_path, sr=SAMPLERATE, mono=True) + + # Batch segments up to ~30s windows by merging contiguous VAD segments + merged_batches: list[TimeSegment] = [] + batch_start = None + batch_end = None + max_duration = VAD_CONFIG["batch_max_duration"] + for segment in vad_segments(audio_array): + seg_start, seg_end = segment.start, segment.end + if batch_start is None: + batch_start, batch_end = seg_start, seg_end + continue + if seg_end - batch_start <= max_duration: + batch_end = seg_end + else: + merged_batches.append(TimeSegment(batch_start, batch_end)) + batch_start, batch_end = seg_start, seg_end + if batch_start is not None and batch_end is not None: + merged_batches.append(TimeSegment(batch_start, batch_end)) + + all_text = [] + all_words = [] + + for segment in merged_batches: + start_time, end_time = segment.start, segment.end + s_idx = int(start_time * SAMPLERATE) + e_idx = int(end_time * SAMPLERATE) + segment = audio_array[s_idx:e_idx] + segment = pad_audio(segment, SAMPLERATE) with self.lock: segments, _ = self.model.transcribe( - fp.name, + segment, language=language, beam_size=5, word_timestamps=True, @@ -96,66 +389,220 @@ class Transcriber: ) segments = list(segments) - text = "".join(segment.text for segment in segments) + text = "".join(seg.text for seg in segments).strip() words = [ - {"word": word.word, "start": word.start, "end": word.end} - for segment in segments - for word in segment.words + { + "word": w.word, + "start": round(float(w.start) + start_time + timestamp_offset, 2), + "end": round(float(w.end) + start_time + timestamp_offset, 2), + } + for seg in segments + for w in seg.words ] + if text: + all_text.append(text) + all_words.extend(words) - return {"text": text, "words": words} + return {"text": " ".join(all_text), "words": all_words} + + +def detect_audio_format(url: str, headers: dict) -> str: + from urllib.parse import urlparse + + from fastapi import HTTPException + + url_path = urlparse(url).path + for ext in SUPPORTED_FILE_EXTENSIONS: + if url_path.lower().endswith(f".{ext}"): + return ext + + content_type = headers.get("content-type", "").lower() + if "audio/mpeg" in content_type or "audio/mp3" in content_type: + return "mp3" + if "audio/wav" in content_type: + return "wav" + if "audio/mp4" in content_type: + return "mp4" + + raise HTTPException( + status_code=400, + detail=( + f"Unsupported audio format for URL. Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}" + ), + ) + + +def download_audio_to_volume(audio_file_url: str) -> tuple[str, str]: + import requests + from fastapi import HTTPException + + response = requests.head(audio_file_url, allow_redirects=True) + if response.status_code == 404: + raise HTTPException(status_code=404, detail="Audio file not found") + + response = requests.get(audio_file_url, allow_redirects=True) + response.raise_for_status() + + audio_suffix = detect_audio_format(audio_file_url, response.headers) + unique_filename = f"{uuid.uuid4()}.{audio_suffix}" + file_path = f"{UPLOADS_PATH}/{unique_filename}" + + with open(file_path, "wb") as f: + f.write(response.content) + + upload_volume.commit() + return unique_filename, audio_suffix @app.function( scaledown_window=60, - timeout=60, - allow_concurrent_inputs=40, + timeout=600, secrets=[ modal.Secret.from_name("reflector-gpu"), ], - volumes={MODELS_DIR: volume}, + volumes={CACHE_PATH: model_cache, UPLOADS_PATH: upload_volume}, + image=image, ) +@modal.concurrent(max_inputs=40) @modal.asgi_app() def web(): - from fastapi import Body, Depends, FastAPI, HTTPException, UploadFile, status + from fastapi import ( + Body, + Depends, + FastAPI, + Form, + HTTPException, + UploadFile, + status, + ) from fastapi.security import OAuth2PasswordBearer - from typing_extensions import Annotated - transcriber = Transcriber() + transcriber_live = TranscriberWhisperLive() + transcriber_file = TranscriberWhisperFile() app = FastAPI() oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") - supported_file_types = ["mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"] - def apikey_auth(apikey: str = Depends(oauth2_scheme)): - if apikey != os.environ["REFLECTOR_GPU_APIKEY"]: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid API key", - headers={"WWW-Authenticate": "Bearer"}, - ) + if apikey == os.environ["REFLECTOR_GPU_APIKEY"]: + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key", + headers={"WWW-Authenticate": "Bearer"}, + ) - class TranscriptResponse(BaseModel): - result: dict + class TranscriptResponse(dict): + pass @app.post("/v1/audio/transcriptions", dependencies=[Depends(apikey_auth)]) def transcribe( - file: UploadFile, - model: str = "whisper-1", - language: Annotated[str, Body(...)] = "en", - ) -> TranscriptResponse: - audio_data = file.file.read() - audio_suffix = file.filename.split(".")[-1] - assert audio_suffix in supported_file_types + file: UploadFile = None, + files: list[UploadFile] | None = None, + model: str = Form(MODEL_NAME), + language: str = Form("en"), + batch: bool = Form(False), + ): + if not file and not files: + raise HTTPException( + status_code=400, detail="Either 'file' or 'files' parameter is required" + ) + if batch and not files: + raise HTTPException( + status_code=400, detail="Batch transcription requires 'files'" + ) - func = transcriber.transcribe_segment.spawn( - audio_data=audio_data, - audio_suffix=audio_suffix, - language=language, - ) - result = func.get() - return result + upload_files = [file] if file else files + + uploaded_filenames: list[str] = [] + for upload_file in upload_files: + audio_suffix = upload_file.filename.split(".")[-1] + if audio_suffix not in SUPPORTED_FILE_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=( + f"Unsupported audio format. Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}" + ), + ) + + unique_filename = f"{uuid.uuid4()}.{audio_suffix}" + file_path = f"{UPLOADS_PATH}/{unique_filename}" + with open(file_path, "wb") as f: + content = upload_file.file.read() + f.write(content) + uploaded_filenames.append(unique_filename) + + upload_volume.commit() + + try: + if batch and len(upload_files) > 1: + func = transcriber_live.transcribe_batch.spawn( + filenames=uploaded_filenames, + language=language, + ) + results = func.get() + return {"results": results} + + results = [] + for filename in uploaded_filenames: + func = transcriber_live.transcribe_segment.spawn( + filename=filename, + language=language, + ) + result = func.get() + result["filename"] = filename + results.append(result) + + return {"results": results} if len(results) > 1 else results[0] + finally: + for filename in uploaded_filenames: + try: + file_path = f"{UPLOADS_PATH}/{filename}" + os.remove(file_path) + except Exception: + pass + upload_volume.commit() + + @app.post("/v1/audio/transcriptions-from-url", dependencies=[Depends(apikey_auth)]) + def transcribe_from_url( + audio_file_url: str = Body( + ..., description="URL of the audio file to transcribe" + ), + model: str = Body(MODEL_NAME), + language: str = Body("en"), + timestamp_offset: float = Body(0.0), + ): + unique_filename, _audio_suffix = download_audio_to_volume(audio_file_url) + try: + func = transcriber_file.transcribe_segment.spawn( + filename=unique_filename, + timestamp_offset=timestamp_offset, + language=language, + ) + result = func.get() + return result + finally: + try: + file_path = f"{UPLOADS_PATH}/{unique_filename}" + os.remove(file_path) + upload_volume.commit() + except Exception: + pass return app + + +class NoStdStreams: + def __init__(self): + self.devnull = open(os.devnull, "w") + + def __enter__(self): + self._stdout, self._stderr = sys.stdout, sys.stderr + self._stdout.flush() + self._stderr.flush() + sys.stdout, sys.stderr = self.devnull, self.devnull + + def __exit__(self, exc_type, exc_value, traceback): + sys.stdout, sys.stderr = self._stdout, self._stderr + self.devnull.close() diff --git a/server/tests/test_gpu_modal_transcript.py b/server/tests/test_gpu_modal_transcript.py index 9b37fbe6..9a152185 100644 --- a/server/tests/test_gpu_modal_transcript.py +++ b/server/tests/test_gpu_modal_transcript.py @@ -272,6 +272,9 @@ class TestGPUModalTranscript: for f in temp_files: Path(f).unlink(missing_ok=True) + @pytest.mark.skipif( + not "parakeet" in get_model_name(), reason="Parakeet only supports English" + ) def test_transcriptions_error_handling(self): """Test error handling for invalid requests.""" url = get_modal_transcript_url() From c4d2825c81f81ad8835629fbf6ea8c7383f8c31b Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Fri, 5 Sep 2025 18:01:31 -0400 Subject: [PATCH 03/20] feat: frontend openapi react query (#606) * refactor: migrate from @hey-api/openapi-ts to openapi-react-query - Replace @hey-api/openapi-ts with openapi-typescript and openapi-react-query - Generate TypeScript types from OpenAPI spec - Set up React Query infrastructure with QueryClientProvider - Migrate all API hooks to use React Query patterns - Maintain backward compatibility for existing components - Remove old API infrastructure and dependencies * fix: resolve import errors and add missing api hooks - Create constants.ts for RECORD_A_MEETING_URL - Add api-types.ts for backward compatible type exports - Update all imports from deleted api folder to new locations - Add missing React Query hooks for rooms and zulip operations - Create useApi compatibility layer for unmigrated components * feat: migrate components to React Query hooks - Add comprehensive API hooks for all operations - Migrate rooms page to use React Query mutations - Update transcript title component to use mutation hook - Refactor share/privacy component with proper error handling - Remove direct API client usage in favor of hooks * feat: complete migration from @hey-api/openapi-ts to openapi-react-query - Migrated all components from useApi compatibility layer to direct React Query hooks - Added new hooks for participant operations, room meetings, and speaker operations - Updated all imports from old api module to api-types - Fixed TypeScript types and API endpoint signatures - Removed deprecated useApi.ts compatibility layer - Fixed SourceKind enum values to match OpenAPI spec - Added @ts-ignore for Zulip endpoints not in OpenAPI spec yet - Fixed all compilation errors and type issues * fix: authentication flow with React Query migration - Fix middleware management in apiClient to properly handle auth tokens - Update ApiAuthProvider to correctly configure base URL and auth - Add missing NextAuth API route handler at app/api/auth/[...nextauth]/route.ts - Remove middleware ejection attempts (not supported by openapi-fetch) - Use global variables to store current auth token and API URL - Setup middleware once on initialization instead of repeatedly adding This fixes the login/logout flow that was broken after migrating from the useApi compatibility layer to native React Query hooks. * fix: prevent unauthorized API calls before authentication - Add global AuthGuard component to handle authentication at layout level - Make all API query hooks conditional on authentication status - Define public routes (like /transcripts/new) that don't require auth - Fix login flow to use NextAuth signIn instead of non-existent /login route - Prevent 401 errors by waiting for auth token before making API calls Previously, all routes under (app) were publicly accessible with each page handling auth individually. Now authentication is enforced globally while still allowing specific routes to remain public. * refactor: remove redundant client-side AuthGuard The authentication is already properly handled by Next.js middleware in middleware.ts with LOGIN_REQUIRED_PAGES. The middleware approach is superior as it: - Provides server-side protection before page loads - Prevents flash of unauthorized content - Centralizes auth logic in one place - Better performance (no client-side JS needed) Keep the API hooks conditional to prevent 401 errors before token is ready. * fix: use direct status check for API query authentication Changed all query hooks to use direct `status === "authenticated"` check instead of derived `isAuthenticated && !isLoading` to avoid race conditions where queries might fire before the authentication token is properly set. This prevents the brief 401 errors that occur on page refresh when the session is being restored. * fix: correct content-type header for FormData uploads Previously, the API client was setting a default Content-Type of application/json for all requests, which broke file uploads that need multipart/form-data. Now the client only sets application/json when the body is not FormData, allowing FormData to automatically set the correct multipart boundary. * fix: resolve authentication race condition with React Query Previously, API calls were being made before the auth token was configured, causing initial 401 errors that would retry with 200 after token setup. Changes: - Add global auth readiness tracking in apiClient - Create useAuthReady hook that checks both session and token state - Update all API hooks to use isAuthReady instead of just session status - Add AuthWrapper component at layout level for consistent loading UX - Show spinner while authentication initializes across all pages This ensures API calls only fire after authentication is fully configured, eliminating the 401/retry pattern and improving user experience. * refactor: clean up api-hooks.ts comments and improve search invalidation - Remove redundant function category comments (exports are self-explanatory) - Remove obvious inline comments for query invalidation - Fix search endpoint invalidation to clear all queries regardless of parameters * refactor: remove api-types.ts compatibility layer - Migrated all 29 files from api-types.ts to use reflector-api.d.ts directly - Removed $SourceKind manual enum in favor of OpenAPI-generated types - Fixed unrelated Spinner component TypeScript error in AuthWrapper.tsx - All imports now use: import type { components } from "path/to/reflector-api" - Deleted api-types.ts file completely * refactor: rename api-hooks.ts to apiHooks.ts for consistency - Renamed api-hooks.ts to apiHooks.ts to follow camelCase convention - Updated all 21 import statements across the codebase - Maintains consistency with other non-component files (apiClient.tsx, useAuthReady.ts, etc.) - Follows established naming pattern: PascalCase for components, camelCase for utilities/hooks * chore: add .playwright-mcp to .gitignore * refactor: remove SK helper object and use inline type casting in FilterSidebar Replace the SK (SourceKind) helper object with direct inline type casting to simplify the code and reduce unnecessary abstraction. * chore: clean up migration comments from React Query refactoring - Remove temporary "// Use new React Query hooks" comments - Remove "// React Query hooks" comments from browse and rooms pages - Update package.json script name from codegen to openapi for consistency * refactor: remove Redis dependencies from frontend authentication - Replace Redis/Redlock with in-memory cache for token management - Remove @vercel/kv, ioredis, and redlock dependencies from package.json - Implement simple lock mechanism for concurrent token refresh prevention - Use Map-based cache with TTL for token storage - Maintain same authentication flow without external dependencies This simplifies the infrastructure requirements and removes the need for Redis while maintaining the same functionality through in-memory caching. * fix: add staleTime to prevent cross-tab staled data * fix: remove infinite re-render loop in useSessionAccessToken The hook was maintaining redundant local state that caused re-renders on every update, which triggered NextAuth to continuously refetch the session, resulting in hundreds of POST requests to /api/auth/session. Simplified the hook to directly return session values without unnecessary state duplication. * fix: handle undefined access tokens in auth.ts Added fallback to empty string for potentially undefined access_token and refresh_token from NextAuth account object to satisfy JWTWithAccessToken type requirements. * Igor/mathieu/frontend openapi react query (#597) * small typing * typing fixes --------- Co-authored-by: Igor Loskutov * self-review-fix * authReady callback simplify * fix auth * fix compose * room detail page fix * compile fix * room edit fix * normalize auth provider * room edition state granular management * cover TODOs + cross-tab cache * session auto refresh blink * schema generator error type doc * protect from zombie auth * clarify access token refresh logic a bit * remove react-query tab sharing cache * remove react-query tab sharing cache * websocket dupe react devmode protection * invalidate room on room update * redis cache * test ts server * ci randomness * less edgy config (ci) * less edgy config (ci) * less edgy config (ci) * ci randomness * ci randomness * ci randomness * ci randomness * less edgy config (ci) * added vs edited room state cleanup * file upload real-time state management fix * prettier auth state ternary * prettier auth state ternary * proper api address from env * INTERVAL_REFRESH_MS * node version 20 for tests * github debug * github debug * github debug * github debug * github debug * github debug * github debug * github debug * github debug * github debug * github debug * CI debug * CI debug * nextjs magic * nextjs magic * doc * client-side stale auth soft safety net --------- Co-authored-by: Mathieu Virbel Co-authored-by: Igor Loskutov --- .github/workflows/test_next_server.yml | 45 + .gitignore | 1 + compose.yml | 3 + server/reflector/views/rooms.py | 1 + server/reflector/views/transcripts.py | 3 +- server/runserver.sh | 2 +- www/app/(app)/AuthWrapper.tsx | 30 + .../browse/_components/FilterSidebar.tsx | 7 +- .../browse/_components/TranscriptCards.tsx | 7 +- www/app/(app)/browse/page.tsx | 149 +- www/app/(app)/layout.tsx | 5 +- www/app/(app)/rooms/_components/RoomCards.tsx | 6 +- www/app/(app)/rooms/_components/RoomList.tsx | 6 +- www/app/(app)/rooms/_components/RoomTable.tsx | 6 +- www/app/(app)/rooms/page.tsx | 233 +- www/app/(app)/rooms/useRoomList.tsx | 49 +- .../[transcriptId]/correct/page.tsx | 28 +- .../correct/participantList.tsx | 197 +- .../[transcriptId]/correct/topicHeader.tsx | 3 +- .../[transcriptId]/finalSummary.tsx | 37 +- .../(app)/transcripts/[transcriptId]/page.tsx | 4 +- .../[transcriptId]/upload/page.tsx | 13 +- www/app/(app)/transcripts/createTranscript.ts | 46 +- .../(app)/transcripts/fileUploadButton.tsx | 64 +- www/app/(app)/transcripts/new/page.tsx | 39 +- www/app/(app)/transcripts/player.tsx | 10 +- www/app/(app)/transcripts/recorder.tsx | 1 - www/app/(app)/transcripts/shareAndPrivacy.tsx | 47 +- www/app/(app)/transcripts/shareCopy.tsx | 4 +- www/app/(app)/transcripts/shareZulip.tsx | 145 +- www/app/(app)/transcripts/transcriptTitle.tsx | 21 +- www/app/(app)/transcripts/useMp3.ts | 108 +- www/app/(app)/transcripts/useParticipants.ts | 74 +- .../(app)/transcripts/useSearchTranscripts.ts | 123 - .../(app)/transcripts/useTopicWithWords.ts | 78 +- www/app/(app)/transcripts/useTopics.ts | 42 +- www/app/(app)/transcripts/useTranscript.ts | 75 +- www/app/(app)/transcripts/useWaveform.ts | 47 +- www/app/(app)/transcripts/useWebRTC.ts | 44 +- www/app/(app)/transcripts/useWebSockets.ts | 62 +- www/app/(app)/transcripts/webSocketTypes.ts | 4 +- www/app/(auth)/userInfo.tsx | 17 +- www/app/[roomName]/page.tsx | 42 +- www/app/[roomName]/useRoomMeeting.tsx | 43 +- www/app/api/OpenApi.ts | 37 - www/app/api/auth/[...nextauth]/route.ts | 5 +- www/app/api/core/ApiError.ts | 25 - www/app/api/core/ApiRequestOptions.ts | 21 - www/app/api/core/ApiResult.ts | 7 - www/app/api/core/AxiosHttpRequest.ts | 23 - www/app/api/core/BaseHttpRequest.ts | 11 - www/app/api/core/CancelablePromise.ts | 126 - www/app/api/core/OpenAPI.ts | 57 - www/app/api/core/request.ts | 387 --- www/app/api/index.ts | 9 - www/app/api/schemas.gen.ts | 1776 ---------- www/app/api/services.gen.ts | 942 ------ www/app/api/types.gen.ts | 1143 ------- www/app/api/urls.ts | 1 - www/app/layout.tsx | 23 +- www/app/lib/AuthProvider.tsx | 104 + www/app/lib/SessionAutoRefresh.tsx | 38 +- www/app/lib/SessionProvider.tsx | 11 - www/app/lib/__tests__/redisTokenCache.test.ts | 85 + www/app/lib/apiClient.tsx | 50 + www/app/lib/apiHooks.ts | 618 ++++ www/app/lib/auth.ts | 166 +- www/app/lib/authBackend.ts | 178 + www/app/lib/edgeConfig.ts | 14 +- www/app/lib/next.ts | 2 + www/app/lib/queryClient.tsx | 17 + www/app/lib/redisClient.ts | 46 + www/app/lib/redisTokenCache.ts | 61 + www/app/lib/types.ts | 68 +- www/app/lib/useApi.ts | 37 - www/app/lib/useLoginRequiredPages.ts | 26 + www/app/lib/useSessionAccessToken.ts | 42 - www/app/lib/useSessionStatus.ts | 22 - www/app/lib/useSessionUser.ts | 33 - www/app/lib/useUserName.ts | 7 + www/app/lib/utils.ts | 23 +- www/app/providers.tsx | 22 +- www/app/reflector-api.d.ts | 2330 +++++++++++++ www/jest.config.js | 8 + www/middleware.ts | 11 +- www/next.config.js | 3 + www/openapi-ts.config.ts | 14 - www/package.json | 19 +- www/pnpm-lock.yaml | 2900 +++++++++++++++-- www/public/service-worker.js | 2 +- 90 files changed, 7253 insertions(+), 6268 deletions(-) create mode 100644 .github/workflows/test_next_server.yml create mode 100644 www/app/(app)/AuthWrapper.tsx delete mode 100644 www/app/(app)/transcripts/useSearchTranscripts.ts delete mode 100644 www/app/api/OpenApi.ts delete mode 100644 www/app/api/core/ApiError.ts delete mode 100644 www/app/api/core/ApiRequestOptions.ts delete mode 100644 www/app/api/core/ApiResult.ts delete mode 100644 www/app/api/core/AxiosHttpRequest.ts delete mode 100644 www/app/api/core/BaseHttpRequest.ts delete mode 100644 www/app/api/core/CancelablePromise.ts delete mode 100644 www/app/api/core/OpenAPI.ts delete mode 100644 www/app/api/core/request.ts delete mode 100644 www/app/api/index.ts create mode 100644 www/app/lib/AuthProvider.tsx delete mode 100644 www/app/lib/SessionProvider.tsx create mode 100644 www/app/lib/__tests__/redisTokenCache.test.ts create mode 100644 www/app/lib/apiClient.tsx create mode 100644 www/app/lib/apiHooks.ts create mode 100644 www/app/lib/authBackend.ts create mode 100644 www/app/lib/next.ts create mode 100644 www/app/lib/queryClient.tsx create mode 100644 www/app/lib/redisClient.ts create mode 100644 www/app/lib/redisTokenCache.ts delete mode 100644 www/app/lib/useApi.ts create mode 100644 www/app/lib/useLoginRequiredPages.ts delete mode 100644 www/app/lib/useSessionAccessToken.ts delete mode 100644 www/app/lib/useSessionStatus.ts delete mode 100644 www/app/lib/useSessionUser.ts create mode 100644 www/app/lib/useUserName.ts create mode 100644 www/app/reflector-api.d.ts create mode 100644 www/jest.config.js delete mode 100644 www/openapi-ts.config.ts diff --git a/.github/workflows/test_next_server.yml b/.github/workflows/test_next_server.yml new file mode 100644 index 00000000..892566d6 --- /dev/null +++ b/.github/workflows/test_next_server.yml @@ -0,0 +1,45 @@ +name: Test Next Server + +on: + pull_request: + paths: + - "www/**" + push: + branches: + - main + paths: + - "www/**" + +jobs: + test-next-server: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./www + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 8 + + - name: Setup Node.js cache + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: './www/pnpm-lock.yaml' + + - name: Install dependencies + run: pnpm install + + - name: Run tests + run: pnpm test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 29d56f25..f3249991 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ server/test.sqlite CLAUDE.local.md www/.env.development www/.env.production +.playwright-mcp diff --git a/compose.yml b/compose.yml index 492c7b8c..acbfd3b5 100644 --- a/compose.yml +++ b/compose.yml @@ -6,6 +6,7 @@ services: - 1250:1250 volumes: - ./server/:/app/ + - /app/.venv env_file: - ./server/.env environment: @@ -16,6 +17,7 @@ services: context: server volumes: - ./server/:/app/ + - /app/.venv env_file: - ./server/.env environment: @@ -26,6 +28,7 @@ services: context: server volumes: - ./server/:/app/ + - /app/.venv env_file: - ./server/.env environment: diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 40e81aeb..cc00f3c0 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -197,6 +197,7 @@ async def rooms_create_meeting( end_date = current_time + timedelta(hours=8) whereby_meeting = await create_meeting("", end_date=end_date, room=room) + await upload_logo(whereby_meeting["roomName"], "./images/logo.png") # Now try to save to database diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 3f32a9bd..9acfcbf8 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -27,6 +27,7 @@ from reflector.db.search import ( from reflector.db.transcripts import ( SourceKind, TranscriptParticipant, + TranscriptStatus, TranscriptTopic, transcripts_controller, ) @@ -63,7 +64,7 @@ class GetTranscriptMinimal(BaseModel): id: str user_id: str | None name: str - status: str + status: TranscriptStatus locked: bool duration: float title: str | None diff --git a/server/runserver.sh b/server/runserver.sh index a4fb6869..9cccaacb 100755 --- a/server/runserver.sh +++ b/server/runserver.sh @@ -2,7 +2,7 @@ if [ "${ENTRYPOINT}" = "server" ]; then uv run alembic upgrade head - uv run -m reflector.app + uv run uvicorn reflector.app:app --host 0.0.0.0 --port 1250 elif [ "${ENTRYPOINT}" = "worker" ]; then uv run celery -A reflector.worker.app worker --loglevel=info elif [ "${ENTRYPOINT}" = "beat" ]; then diff --git a/www/app/(app)/AuthWrapper.tsx b/www/app/(app)/AuthWrapper.tsx new file mode 100644 index 00000000..57038b7b --- /dev/null +++ b/www/app/(app)/AuthWrapper.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Flex, Spinner } from "@chakra-ui/react"; +import { useAuth } from "../lib/AuthProvider"; +import { useLoginRequiredPages } from "../lib/useLoginRequiredPages"; + +export default function AuthWrapper({ + children, +}: { + children: React.ReactNode; +}) { + const auth = useAuth(); + const redirectPath = useLoginRequiredPages(); + const redirectHappens = !!redirectPath; + + if (auth.status === "loading" || redirectHappens) { + return ( + + + + ); + } + + return <>{children}; +} diff --git a/www/app/(app)/browse/_components/FilterSidebar.tsx b/www/app/(app)/browse/_components/FilterSidebar.tsx index b2abe481..6eef61b8 100644 --- a/www/app/(app)/browse/_components/FilterSidebar.tsx +++ b/www/app/(app)/browse/_components/FilterSidebar.tsx @@ -1,7 +1,10 @@ import React from "react"; import { Box, Stack, Link, Heading } from "@chakra-ui/react"; import NextLink from "next/link"; -import { Room, SourceKind } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type Room = components["schemas"]["Room"]; +type SourceKind = components["schemas"]["SourceKind"]; interface FilterSidebarProps { rooms: Room[]; @@ -72,7 +75,7 @@ export default function FilterSidebar({ key={room.id} as={NextLink} href="#" - onClick={() => onFilterChange("room", room.id)} + onClick={() => onFilterChange("room" as SourceKind, room.id)} color={ selectedSourceKind === "room" && selectedRoomId === room.id ? "blue.500" diff --git a/www/app/(app)/browse/_components/TranscriptCards.tsx b/www/app/(app)/browse/_components/TranscriptCards.tsx index b67e71e7..8dbc3568 100644 --- a/www/app/(app)/browse/_components/TranscriptCards.tsx +++ b/www/app/(app)/browse/_components/TranscriptCards.tsx @@ -18,7 +18,10 @@ import { highlightMatches, generateTextFragment, } from "../../../lib/textHighlight"; -import { SearchResult } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type SearchResult = components["schemas"]["SearchResult"]; +type SourceKind = components["schemas"]["SourceKind"]; interface TranscriptCardsProps { results: SearchResult[]; @@ -120,7 +123,7 @@ function TranscriptCard({ : "N/A"; const formattedDate = formatLocalDate(result.created_at); const source = - result.source_kind === "room" + result.source_kind === ("room" as SourceKind) ? result.room_name || result.room_id : result.source_kind; diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx index e7522e14..8523650e 100644 --- a/www/app/(app)/browse/page.tsx +++ b/www/app/(app)/browse/page.tsx @@ -19,37 +19,33 @@ import { parseAsStringLiteral, } from "nuqs"; import { LuX } from "react-icons/lu"; -import { useSearchTranscripts } from "../transcripts/useSearchTranscripts"; -import useSessionUser from "../../lib/useSessionUser"; -import { Room, SourceKind, SearchResult, $SourceKind } from "../../api"; -import useApi from "../../lib/useApi"; -import { useError } from "../../(errors)/errorContext"; +import type { components } from "../../reflector-api"; + +type Room = components["schemas"]["Room"]; +type SourceKind = components["schemas"]["SourceKind"]; +type SearchResult = components["schemas"]["SearchResult"]; +import { + useRoomsList, + useTranscriptsSearch, + useTranscriptDelete, + useTranscriptProcess, +} from "../../lib/apiHooks"; import FilterSidebar from "./_components/FilterSidebar"; import Pagination, { FIRST_PAGE, PaginationPage, parsePaginationPage, totalPages as getTotalPages, + paginationPageTo0Based, } from "./_components/Pagination"; import TranscriptCards from "./_components/TranscriptCards"; import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; import { formatLocalDate } from "../../lib/time"; import { RECORD_A_MEETING_URL } from "../../api/urls"; +import { useUserName } from "../../lib/useUserName"; const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const; -const usePrefetchRooms = (setRooms: (rooms: Room[]) => void): void => { - const { setError } = useError(); - const api = useApi(); - useEffect(() => { - if (!api) return; - api - .v1RoomsList({ page: 1 }) - .then((rooms) => setRooms(rooms.items)) - .catch((err) => setError(err, "There was an error fetching the rooms")); - }, [api, setError]); -}; - const SearchForm: React.FC<{ setPage: (page: PaginationPage) => void; sourceKind: SourceKind | null; @@ -69,7 +65,6 @@ const SearchForm: React.FC<{ searchQuery, setSearchQuery, }) => { - // to keep the search input controllable + more fine grained control (urlSearchQuery is updated on submits) const [searchInputValue, setSearchInputValue] = useState(searchQuery || ""); const handleSearchQuerySubmit = async (d: FormData) => { await setSearchQuery((d.get(SEARCH_FORM_QUERY_INPUT_NAME) as string) || ""); @@ -163,7 +158,6 @@ const UnderSearchFormFilterIndicators: React.FC<{ p="1px" onClick={() => { setSourceKind(null); - // TODO questionable setRoomId(null); }} _hover={{ bg: "blue.200" }} @@ -209,7 +203,11 @@ export default function TranscriptBrowser() { const [urlSourceKind, setUrlSourceKind] = useQueryState( "source", - parseAsStringLiteral($SourceKind.enum).withOptions({ + parseAsStringLiteral([ + "room", + "live", + "file", + ] as const satisfies SourceKind[]).withOptions({ shallow: false, }), ); @@ -229,46 +227,40 @@ export default function TranscriptBrowser() { useEffect(() => { const maybePage = parsePaginationPage(urlPage); if ("error" in maybePage) { - setPage(FIRST_PAGE).then(() => { - /*may be called n times we dont care*/ - }); + setPage(FIRST_PAGE).then(() => {}); return; } _setSafePage(maybePage.value); }, [urlPage]); - const [rooms, setRooms] = useState([]); - const pageSize = 20; + const { - results, - totalCount: totalResults, - isLoading, - reload, - } = useSearchTranscripts( - urlSearchQuery, - { - roomIds: urlRoomId ? [urlRoomId] : null, - sourceKind: urlSourceKind, - }, - { - pageSize, - page, - }, - ); + data: searchData, + isLoading: searchLoading, + refetch: reloadSearch, + } = useTranscriptsSearch(urlSearchQuery, { + limit: pageSize, + offset: paginationPageTo0Based(page) * pageSize, + room_id: urlRoomId || undefined, + source_kind: urlSourceKind || undefined, + }); + + const results = searchData?.results || []; + const totalResults = searchData?.total || 0; + + // Fetch rooms + const { data: roomsData } = useRoomsList(1); + const rooms = roomsData?.items || []; const totalPages = getTotalPages(totalResults, pageSize); - const userName = useSessionUser().name; + const userName = useUserName(); const [deletionLoading, setDeletionLoading] = useState(false); - const api = useApi(); - const { setError } = useError(); const cancelRef = React.useRef(null); const [transcriptToDeleteId, setTranscriptToDeleteId] = React.useState(); - usePrefetchRooms(setRooms); - const handleFilterTranscripts = ( sourceKind: SourceKind | null, roomId: string, @@ -280,44 +272,37 @@ export default function TranscriptBrowser() { const onCloseDeletion = () => setTranscriptToDeleteId(undefined); + const deleteTranscript = useTranscriptDelete(); + const processTranscript = useTranscriptProcess(); + const confirmDeleteTranscript = (transcriptId: string) => { - if (!api || deletionLoading) return; + if (deletionLoading) return; setDeletionLoading(true); - api - .v1TranscriptDelete({ transcriptId }) - .then(() => { - setDeletionLoading(false); - onCloseDeletion(); - reload(); - }) - .catch((err) => { - setDeletionLoading(false); - setError(err, "There was an error deleting the transcript"); - }); + deleteTranscript.mutate( + { + params: { + path: { transcript_id: transcriptId }, + }, + }, + { + onSuccess: () => { + setDeletionLoading(false); + onCloseDeletion(); + reloadSearch(); + }, + onError: () => { + setDeletionLoading(false); + }, + }, + ); }; const handleProcessTranscript = (transcriptId: string) => { - if (!api) { - console.error("API not available on handleProcessTranscript"); - return; - } - api - .v1TranscriptProcess({ transcriptId }) - .then((result) => { - const status = - result && typeof result === "object" && "status" in result - ? (result as { status: string }).status - : undefined; - if (status === "already running") { - setError( - new Error("Processing is already running, please wait"), - "Processing is already running, please wait", - ); - } - }) - .catch((err) => { - setError(err, "There was an error processing the transcript"); - }); + processTranscript.mutate({ + params: { + path: { transcript_id: transcriptId }, + }, + }); }; const transcriptToDelete = results?.find( @@ -332,7 +317,7 @@ export default function TranscriptBrowser() { ? transcriptToDelete.room_name || transcriptToDelete.room_id : transcriptToDelete?.source_kind; - if (isLoading && results.length === 0) { + if (searchLoading && results.length === 0) { return ( {userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "} - {(isLoading || deletionLoading) && } + {(searchLoading || deletionLoading) && } @@ -403,12 +388,12 @@ export default function TranscriptBrowser() { - {!isLoading && results.length === 0 && ( + {!searchLoading && results.length === 0 && ( )} diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx index 5760e19d..801be28f 100644 --- a/www/app/(app)/layout.tsx +++ b/www/app/(app)/layout.tsx @@ -2,9 +2,8 @@ import { Container, Flex, Link } from "@chakra-ui/react"; import { getConfig } from "../lib/edgeConfig"; import NextLink from "next/link"; import Image from "next/image"; -import About from "../(aboutAndPrivacy)/about"; -import Privacy from "../(aboutAndPrivacy)/privacy"; import UserInfo from "../(auth)/userInfo"; +import AuthWrapper from "./AuthWrapper"; import { RECORD_A_MEETING_URL } from "../api/urls"; export default async function AppLayout({ @@ -90,7 +89,7 @@ export default async function AppLayout({ - {children} + {children} ); } diff --git a/www/app/(app)/rooms/_components/RoomCards.tsx b/www/app/(app)/rooms/_components/RoomCards.tsx index 16748d90..8b22ad72 100644 --- a/www/app/(app)/rooms/_components/RoomCards.tsx +++ b/www/app/(app)/rooms/_components/RoomCards.tsx @@ -12,11 +12,13 @@ import { HStack, } from "@chakra-ui/react"; import { LuLink } from "react-icons/lu"; -import { RoomDetails } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type Room = components["schemas"]["Room"]; import { RoomActionsMenu } from "./RoomActionsMenu"; interface RoomCardsProps { - rooms: RoomDetails[]; + rooms: Room[]; linkCopied: string; onCopyUrl: (roomName: string) => void; onEdit: (roomId: string, roomData: any) => void; diff --git a/www/app/(app)/rooms/_components/RoomList.tsx b/www/app/(app)/rooms/_components/RoomList.tsx index 73fe8a5c..218c890c 100644 --- a/www/app/(app)/rooms/_components/RoomList.tsx +++ b/www/app/(app)/rooms/_components/RoomList.tsx @@ -1,11 +1,13 @@ import { Box, Heading, Text, VStack } from "@chakra-ui/react"; -import { RoomDetails } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type Room = components["schemas"]["Room"]; import { RoomTable } from "./RoomTable"; import { RoomCards } from "./RoomCards"; interface RoomListProps { title: string; - rooms: RoomDetails[]; + rooms: Room[]; linkCopied: string; onCopyUrl: (roomName: string) => void; onEdit: (roomId: string, roomData: any) => void; diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx index 93d05b61..113eca7f 100644 --- a/www/app/(app)/rooms/_components/RoomTable.tsx +++ b/www/app/(app)/rooms/_components/RoomTable.tsx @@ -9,11 +9,13 @@ import { Spinner, } from "@chakra-ui/react"; import { LuLink } from "react-icons/lu"; -import { RoomDetails } from "../../../api"; +import type { components } from "../../../reflector-api"; + +type Room = components["schemas"]["Room"]; import { RoomActionsMenu } from "./RoomActionsMenu"; interface RoomTableProps { - rooms: RoomDetails[]; + rooms: Room[]; linkCopied: string; onCopyUrl: (roomName: string) => void; onEdit: (roomId: string, roomData: any) => void; diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 33cfa6b3..8b1378df 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -15,13 +15,24 @@ import { createListCollection, useDisclosure, } from "@chakra-ui/react"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { LuEye, LuEyeOff } from "react-icons/lu"; -import useApi from "../../lib/useApi"; import useRoomList from "./useRoomList"; -import { ApiError, RoomDetails } from "../../api"; +import type { components } from "../../reflector-api"; +import { + useRoomCreate, + useRoomUpdate, + useRoomDelete, + useZulipStreams, + useZulipTopics, + useRoomGet, + useRoomTestWebhook, +} from "../../lib/apiHooks"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; +import { assertExists } from "../../lib/utils"; + +type Room = components["schemas"]["Room"]; interface SelectOption { label: string; @@ -76,66 +87,77 @@ export default function RoomsList() { const recordingTypeCollection = createListCollection({ items: recordingTypeOptions, }); - const [room, setRoom] = useState(roomInitialState); + const [roomInput, setRoomInput] = useState( + null, + ); const [isEditing, setIsEditing] = useState(false); - const [editRoomId, setEditRoomId] = useState(""); - const api = useApi(); - // TODO seems to be no setPage calls - const [page, setPage] = useState(1); - const { loading, response, refetch } = useRoomList(PaginationPage(page)); - const [streams, setStreams] = useState([]); - const [topics, setTopics] = useState([]); + const [editRoomId, setEditRoomId] = useState(null); + const { + loading, + response, + refetch, + error: roomListError, + } = useRoomList(PaginationPage(1)); const [nameError, setNameError] = useState(""); const [linkCopied, setLinkCopied] = useState(""); + const [selectedStreamId, setSelectedStreamId] = useState(null); const [testingWebhook, setTestingWebhook] = useState(false); const [webhookTestResult, setWebhookTestResult] = useState( null, ); const [showWebhookSecret, setShowWebhookSecret] = useState(false); - interface Stream { - stream_id: number; - name: string; - } - interface Topic { - name: string; - } + const createRoomMutation = useRoomCreate(); + const updateRoomMutation = useRoomUpdate(); + const deleteRoomMutation = useRoomDelete(); + const { data: streams = [] } = useZulipStreams(); + const { data: topics = [] } = useZulipTopics(selectedStreamId); + const { + data: detailedEditedRoom, + isLoading: isDetailedEditedRoomLoading, + error: detailedEditedRoomError, + } = useRoomGet(editRoomId); + + const error = roomListError || detailedEditedRoomError; + + // room being edited, as fetched from the server + const editedRoom: typeof roomInitialState | null = useMemo( + () => + detailedEditedRoom + ? { + name: detailedEditedRoom.name, + zulipAutoPost: detailedEditedRoom.zulip_auto_post, + zulipStream: detailedEditedRoom.zulip_stream, + zulipTopic: detailedEditedRoom.zulip_topic, + isLocked: detailedEditedRoom.is_locked, + roomMode: detailedEditedRoom.room_mode, + recordingType: detailedEditedRoom.recording_type, + recordingTrigger: detailedEditedRoom.recording_trigger, + isShared: detailedEditedRoom.is_shared, + webhookUrl: detailedEditedRoom.webhook_url || "", + webhookSecret: detailedEditedRoom.webhook_secret || "", + } + : null, + [detailedEditedRoom], + ); + + // a room input value or a last api room state + const room = roomInput || editedRoom || roomInitialState; + + const roomTestWebhookMutation = useRoomTestWebhook(); + + // Update selected stream ID when zulip stream changes useEffect(() => { - const fetchZulipStreams = async () => { - if (!api) return; - - try { - const response = await api.v1ZulipGetStreams(); - setStreams(response); - } catch (error) { - console.error("Error fetching Zulip streams:", error); + if (room.zulipStream && streams.length > 0) { + const selectedStream = streams.find((s) => s.name === room.zulipStream); + if (selectedStream !== undefined) { + setSelectedStreamId(selectedStream.stream_id); } - }; - - if (room.zulipAutoPost) { - fetchZulipStreams(); + } else { + setSelectedStreamId(null); } - }, [room.zulipAutoPost, !api]); - - useEffect(() => { - const fetchZulipTopics = async () => { - if (!api || !room.zulipStream) return; - try { - const selectedStream = streams.find((s) => s.name === room.zulipStream); - if (selectedStream) { - const response = await api.v1ZulipGetTopics({ - streamId: selectedStream.stream_id, - }); - setTopics(response); - } - } catch (error) { - console.error("Error fetching Zulip topics:", error); - } - }; - - fetchZulipTopics(); - }, [room.zulipStream, streams, api]); + }, [room.zulipStream, streams]); const streamOptions: SelectOption[] = streams.map((stream) => { return { label: stream.name, value: stream.name }; @@ -167,35 +189,42 @@ export default function RoomsList() { const handleCloseDialog = () => { setShowWebhookSecret(false); setWebhookTestResult(null); + setEditRoomId(null); onClose(); }; const handleTestWebhook = async () => { - if (!room.webhookUrl || !editRoomId) { + if (!room.webhookUrl) { setWebhookTestResult("Please enter a webhook URL first"); return; } + if (!editRoomId) { + console.error("No room ID to test webhook"); + return; + } setTestingWebhook(true); setWebhookTestResult(null); try { - const response = await api?.v1RoomsTestWebhook({ - roomId: editRoomId, + const response = await roomTestWebhookMutation.mutateAsync({ + params: { + path: { + room_id: editRoomId, + }, + }, }); - if (response?.success) { + if (response.success) { setWebhookTestResult( `✅ Webhook test successful! Status: ${response.status_code}`, ); } else { let errorMsg = `❌ Webhook test failed`; - if (response?.status_code) { - errorMsg += ` (Status: ${response.status_code})`; - } - if (response?.error) { + errorMsg += ` (Status: ${response.status_code})`; + if (response.error) { errorMsg += `: ${response.error}`; - } else if (response?.response_preview) { + } else if (response.response_preview) { // Try to parse and extract meaningful error from response // Specific to N8N at the moment, as there is no specification for that // We could just display as is, but decided here to dig a little bit more. @@ -249,27 +278,29 @@ export default function RoomsList() { }; if (isEditing) { - await api?.v1RoomsUpdate({ - roomId: editRoomId, - requestBody: roomData, + await updateRoomMutation.mutateAsync({ + params: { + path: { room_id: assertExists(editRoomId) }, + }, + body: roomData, }); } else { - await api?.v1RoomsCreate({ - requestBody: roomData, + await createRoomMutation.mutateAsync({ + body: roomData, }); } - setRoom(roomInitialState); + setRoomInput(null); setIsEditing(false); setEditRoomId(""); setNameError(""); refetch(); + onClose(); handleCloseDialog(); - } catch (err) { + } catch (err: any) { if ( - err instanceof ApiError && - err.status === 400 && - (err.body as any).detail == "Room name is not unique" + err?.status === 400 && + err?.body?.detail == "Room name is not unique" ) { setNameError( "This room name is already taken. Please choose a different name.", @@ -280,46 +311,11 @@ export default function RoomsList() { } }; - const handleEditRoom = async (roomId, roomData) => { + const handleEditRoom = async (roomId: string, roomData) => { // Reset states setShowWebhookSecret(false); setWebhookTestResult(null); - // Fetch full room details to get webhook fields - try { - const detailedRoom = await api?.v1RoomsGet({ roomId }); - if (detailedRoom) { - setRoom({ - name: detailedRoom.name, - zulipAutoPost: detailedRoom.zulip_auto_post, - zulipStream: detailedRoom.zulip_stream, - zulipTopic: detailedRoom.zulip_topic, - isLocked: detailedRoom.is_locked, - roomMode: detailedRoom.room_mode, - recordingType: detailedRoom.recording_type, - recordingTrigger: detailedRoom.recording_trigger, - isShared: detailedRoom.is_shared, - webhookUrl: detailedRoom.webhook_url || "", - webhookSecret: detailedRoom.webhook_secret || "", - }); - } - } catch (error) { - console.error("Failed to fetch room details, using list data:", error); - // Fallback to using the data from the list - setRoom({ - name: roomData.name, - zulipAutoPost: roomData.zulip_auto_post, - zulipStream: roomData.zulip_stream, - zulipTopic: roomData.zulip_topic, - isLocked: roomData.is_locked, - roomMode: roomData.room_mode, - recordingType: roomData.recording_type, - recordingTrigger: roomData.recording_trigger, - isShared: roomData.is_shared, - webhookUrl: roomData.webhook_url || "", - webhookSecret: roomData.webhook_secret || "", - }); - } setEditRoomId(roomId); setIsEditing(true); setNameError(""); @@ -328,8 +324,10 @@ export default function RoomsList() { const handleDeleteRoom = async (roomId: string) => { try { - await api?.v1RoomsDelete({ - roomId, + await deleteRoomMutation.mutateAsync({ + params: { + path: { room_id: roomId }, + }, }); refetch(); } catch (err) { @@ -346,15 +344,15 @@ export default function RoomsList() { .toLowerCase(); setNameError(""); } - setRoom({ + setRoomInput({ ...room, [name]: type === "checkbox" ? checked : value, }); }; - const myRooms: RoomDetails[] = + const myRooms: Room[] = response?.items.filter((roomData) => !roomData.is_shared) || []; - const sharedRooms: RoomDetails[] = + const sharedRooms: Room[] = response?.items.filter((roomData) => roomData.is_shared) || []; if (loading && !response) @@ -369,6 +367,9 @@ export default function RoomsList() { ); + if (roomListError) + return
{`${roomListError.name}: ${roomListError.message}`}
; + return ( { setIsEditing(false); - setRoom(roomInitialState); + setRoomInput(null); setNameError(""); setShowWebhookSecret(false); setWebhookTestResult(null); @@ -456,7 +457,7 @@ export default function RoomsList() { - setRoom({ ...room, roomMode: e.value[0] }) + setRoomInput({ ...room, roomMode: e.value[0] }) } collection={roomModeCollection} > @@ -486,7 +487,7 @@ export default function RoomsList() { - setRoom({ + setRoomInput({ ...room, recordingType: e.value[0], recordingTrigger: @@ -521,7 +522,7 @@ export default function RoomsList() { - setRoom({ ...room, recordingTrigger: e.value[0] }) + setRoomInput({ ...room, recordingTrigger: e.value[0] }) } collection={recordingTriggerCollection} disabled={room.recordingType !== "cloud"} @@ -576,7 +577,7 @@ export default function RoomsList() { - setRoom({ + setRoomInput({ ...room, zulipStream: e.value[0], zulipTopic: "", @@ -611,7 +612,7 @@ export default function RoomsList() { - setRoom({ ...room, zulipTopic: e.value[0] }) + setRoomInput({ ...room, zulipTopic: e.value[0] }) } collection={topicCollection} disabled={!room.zulipAutoPost} diff --git a/www/app/(app)/rooms/useRoomList.tsx b/www/app/(app)/rooms/useRoomList.tsx index c1021ade..e8d11250 100644 --- a/www/app/(app)/rooms/useRoomList.tsx +++ b/www/app/(app)/rooms/useRoomList.tsx @@ -1,48 +1,27 @@ -import { useEffect, useState } from "react"; -import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { Page_RoomDetails_ } from "../../api"; +import { useRoomsList } from "../../lib/apiHooks"; +import type { components } from "../../reflector-api"; + +type Page_Room_ = components["schemas"]["Page_RoomDetails_"]; import { PaginationPage } from "../browse/_components/Pagination"; type RoomList = { - response: Page_RoomDetails_ | null; + response: Page_Room_ | null; loading: boolean; error: Error | null; refetch: () => void; }; -//always protected +// Wrapper to maintain backward compatibility const useRoomList = (page: PaginationPage): RoomList => { - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); - const [refetchCount, setRefetchCount] = useState(0); - - const refetch = () => { - setLoading(true); - setRefetchCount(refetchCount + 1); + const { data, isLoading, error, refetch } = useRoomsList(page); + return { + response: data || null, + loading: isLoading, + error: error + ? new Error(error.detail ? JSON.stringify(error.detail) : undefined) + : null, + refetch, }; - - useEffect(() => { - if (!api) return; - setLoading(true); - api - .v1RoomsList({ page }) - .then((response) => { - setResponse(response); - setLoading(false); - }) - .catch((err) => { - setResponse(null); - setLoading(false); - setError(err); - setErrorState(err); - }); - }, [!api, page, refetchCount]); - - return { response, loading, error, refetch }; }; export default useRoomList; diff --git a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx index 9eff7b60..c885ca6e 100644 --- a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx @@ -6,9 +6,10 @@ import TopicPlayer from "./topicPlayer"; import useParticipants from "../../useParticipants"; import useTopicWithWords from "../../useTopicWithWords"; import ParticipantList from "./participantList"; -import { GetTranscriptTopic } from "../../../../api"; +import type { components } from "../../../../reflector-api"; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; import { SelectedText, selectedTextIsTimeSlice } from "./types"; -import useApi from "../../../../lib/useApi"; +import { useTranscriptUpdate } from "../../../../lib/apiHooks"; import useTranscript from "../../useTranscript"; import { useError } from "../../../../(errors)/errorContext"; import { useRouter } from "next/navigation"; @@ -23,7 +24,7 @@ export type TranscriptCorrect = { export default function TranscriptCorrect({ params: { transcriptId }, }: TranscriptCorrect) { - const api = useApi(); + const updateTranscriptMutation = useTranscriptUpdate(); const transcript = useTranscript(transcriptId); const stateCurrentTopic = useState(); const [currentTopic, _sct] = stateCurrentTopic; @@ -34,16 +35,21 @@ export default function TranscriptCorrect({ const { setError } = useError(); const router = useRouter(); - const markAsDone = () => { + const markAsDone = async () => { if (transcript.response && !transcript.response.reviewed) { - api - ?.v1TranscriptUpdate({ transcriptId, requestBody: { reviewed: true } }) - .then(() => { - router.push(`/transcripts/${transcriptId}`); - }) - .catch((e) => { - setError(e, "Error marking as done"); + try { + await updateTranscriptMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: { reviewed: true }, }); + router.push(`/transcripts/${transcriptId}`); + } catch (e) { + setError(e as Error, "Error marking as done"); + } } }; diff --git a/www/app/(app)/transcripts/[transcriptId]/correct/participantList.tsx b/www/app/(app)/transcripts/[transcriptId]/correct/participantList.tsx index e9297c4b..7c60ea54 100644 --- a/www/app/(app)/transcripts/[transcriptId]/correct/participantList.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/correct/participantList.tsx @@ -1,8 +1,15 @@ import { faArrowTurnDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ChangeEvent, useEffect, useRef, useState } from "react"; -import { Participant } from "../../../../api"; -import useApi from "../../../../lib/useApi"; +import type { components } from "../../../../reflector-api"; +type Participant = components["schemas"]["Participant"]; +import { + useTranscriptSpeakerAssign, + useTranscriptSpeakerMerge, + useTranscriptParticipantUpdate, + useTranscriptParticipantCreate, + useTranscriptParticipantDelete, +} from "../../../../lib/apiHooks"; import { UseParticipants } from "../../useParticipants"; import { selectedTextIsSpeaker, selectedTextIsTimeSlice } from "./types"; import { useError } from "../../../../(errors)/errorContext"; @@ -30,9 +37,19 @@ const ParticipantList = ({ topicWithWords, stateSelectedText, }: ParticipantList) => { - const api = useApi(); const { setError } = useError(); - const [loading, setLoading] = useState(false); + const speakerAssignMutation = useTranscriptSpeakerAssign(); + const speakerMergeMutation = useTranscriptSpeakerMerge(); + const participantUpdateMutation = useTranscriptParticipantUpdate(); + const participantCreateMutation = useTranscriptParticipantCreate(); + const participantDeleteMutation = useTranscriptParticipantDelete(); + + const loading = + speakerAssignMutation.isPending || + speakerMergeMutation.isPending || + participantUpdateMutation.isPending || + participantCreateMutation.isPending || + participantDeleteMutation.isPending; const [participantInput, setParticipantInput] = useState(""); const inputRef = useRef(null); const [selectedText, setSelectedText] = stateSelectedText; @@ -103,7 +120,6 @@ const ParticipantList = ({ const onSuccess = () => { topicWithWords.refetch(); participants.refetch(); - setLoading(false); setAction(null); setSelectedText(undefined); setSelectedParticipant(undefined); @@ -120,11 +136,14 @@ const ParticipantList = ({ if (loading || participants.loading || topicWithWords.loading) return; if (!selectedTextIsTimeSlice(selectedText)) return; - setLoading(true); try { - await api?.v1TranscriptAssignSpeaker({ - transcriptId, - requestBody: { + await speakerAssignMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: { participant: participant.id, timestamp_from: selectedText.start, timestamp_to: selectedText.end, @@ -132,8 +151,7 @@ const ParticipantList = ({ }); onSuccess(); } catch (error) { - setError(error, "There was an error assigning"); - setLoading(false); + setError(error as Error, "There was an error assigning"); throw error; } }; @@ -141,32 +159,38 @@ const ParticipantList = ({ const mergeSpeaker = (speakerFrom, participantTo: Participant) => async () => { if (loading || participants.loading || topicWithWords.loading) return; - setLoading(true); + if (participantTo.speaker) { try { - await api?.v1TranscriptMergeSpeaker({ - transcriptId, - requestBody: { + await speakerMergeMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: { speaker_from: speakerFrom, speaker_to: participantTo.speaker, }, }); onSuccess(); } catch (error) { - setError(error, "There was an error merging"); - setLoading(false); + setError(error as Error, "There was an error merging"); } } else { try { - await api?.v1TranscriptUpdateParticipant({ - transcriptId, - participantId: participantTo.id, - requestBody: { speaker: speakerFrom }, + await participantUpdateMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + participant_id: participantTo.id, + }, + }, + body: { speaker: speakerFrom }, }); onSuccess(); } catch (error) { - setError(error, "There was an error merging (update)"); - setLoading(false); + setError(error as Error, "There was an error merging (update)"); } } }; @@ -186,105 +210,106 @@ const ParticipantList = ({ (p) => p.speaker == selectedText, ); if (participant && participant.name !== participantInput) { - setLoading(true); - api - ?.v1TranscriptUpdateParticipant({ - transcriptId, - participantId: participant.id, - requestBody: { + try { + await participantUpdateMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + participant_id: participant.id, + }, + }, + body: { name: participantInput, }, - }) - .then(() => { - participants.refetch(); - setLoading(false); - setAction(null); - }) - .catch((e) => { - setError(e, "There was an error renaming"); - setLoading(false); }); + participants.refetch(); + setAction(null); + } catch (e) { + setError(e as Error, "There was an error renaming"); + } } } else if ( action == "Create to rename" && selectedTextIsSpeaker(selectedText) ) { - setLoading(true); - api - ?.v1TranscriptAddParticipant({ - transcriptId, - requestBody: { + try { + await participantCreateMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: { name: participantInput, speaker: selectedText, }, - }) - .then(() => { - participants.refetch(); - setParticipantInput(""); - setOneMatch(undefined); - setLoading(false); - }) - .catch((e) => { - setError(e, "There was an error creating"); - setLoading(false); }); + participants.refetch(); + setParticipantInput(""); + setOneMatch(undefined); + } catch (e) { + setError(e as Error, "There was an error creating"); + } } else if ( action == "Create and assign" && selectedTextIsTimeSlice(selectedText) ) { - setLoading(true); try { - const participant = await api?.v1TranscriptAddParticipant({ - transcriptId, - requestBody: { + const participant = await participantCreateMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: { name: participantInput, }, }); - setLoading(false); assignTo(participant)().catch(() => { // error and loading are handled by assignTo catch participants.refetch(); }); } catch (error) { - setError(e, "There was an error creating"); - setLoading(false); + setError(error as Error, "There was an error creating"); } } else if (action == "Create") { - setLoading(true); - api - ?.v1TranscriptAddParticipant({ - transcriptId, - requestBody: { + try { + await participantCreateMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: { name: participantInput, }, - }) - .then(() => { - participants.refetch(); - setParticipantInput(""); - setLoading(false); - inputRef.current?.focus(); - }) - .catch((e) => { - setError(e, "There was an error creating"); - setLoading(false); }); + participants.refetch(); + setParticipantInput(""); + inputRef.current?.focus(); + } catch (e) { + setError(e as Error, "There was an error creating"); + } } }; - const deleteParticipant = (participantId) => (e) => { + const deleteParticipant = (participantId) => async (e) => { e.stopPropagation(); if (loading || participants.loading || topicWithWords.loading) return; - setLoading(true); - api - ?.v1TranscriptDeleteParticipant({ transcriptId, participantId }) - .then(() => { - participants.refetch(); - setLoading(false); - }) - .catch((e) => { - setError(e, "There was an error deleting"); - setLoading(false); + + try { + await participantDeleteMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + participant_id: participantId, + }, + }, }); + participants.refetch(); + } catch (e) { + setError(e as Error, "There was an error deleting"); + } }; const selectParticipant = (participant) => (e) => { diff --git a/www/app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx b/www/app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx index 1448de80..494d2929 100644 --- a/www/app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/correct/topicHeader.tsx @@ -1,6 +1,7 @@ import useTopics from "../../useTopics"; import { Dispatch, SetStateAction, useEffect } from "react"; -import { GetTranscriptTopic } from "../../../../api"; +import type { components } from "../../../../reflector-api"; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; import { BoxProps, Box, diff --git a/www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx b/www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx index 4ce4a9e1..b1f61d43 100644 --- a/www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx @@ -2,12 +2,10 @@ import { useEffect, useRef, useState } from "react"; import React from "react"; import Markdown from "react-markdown"; import "../../../styles/markdown.css"; -import { - GetTranscript, - GetTranscriptTopic, - UpdateTranscript, -} from "../../../api"; -import useApi from "../../../lib/useApi"; +import type { components } from "../../../reflector-api"; +type GetTranscript = components["schemas"]["GetTranscript"]; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; +import { useTranscriptUpdate } from "../../../lib/apiHooks"; import { Flex, Heading, @@ -33,9 +31,8 @@ export default function FinalSummary(props: FinalSummaryProps) { const [preEditSummary, setPreEditSummary] = useState(""); const [editedSummary, setEditedSummary] = useState(""); - const api = useApi(); - const { setError } = useError(); + const updateTranscriptMutation = useTranscriptUpdate(); useEffect(() => { setEditedSummary(props.transcriptResponse?.long_summary || ""); @@ -47,12 +44,15 @@ export default function FinalSummary(props: FinalSummaryProps) { const updateSummary = async (newSummary: string, transcriptId: string) => { try { - const requestBody: UpdateTranscript = { - long_summary: newSummary, - }; - const updatedTranscript = await api?.v1TranscriptUpdate({ - transcriptId, - requestBody, + const updatedTranscript = await updateTranscriptMutation.mutateAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: { + long_summary: newSummary, + }, }); if (props.onUpdate) { props.onUpdate(newSummary); @@ -60,7 +60,7 @@ export default function FinalSummary(props: FinalSummaryProps) { console.log("Updated long summary:", updatedTranscript); } catch (err) { console.error("Failed to update long summary:", err); - setError(err, "Failed to update long summary."); + setError(err as Error, "Failed to update long summary."); } }; @@ -114,7 +114,12 @@ export default function FinalSummary(props: FinalSummaryProps) { - + )} {!isEditMode && ( diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx index 0a2dba47..ce48e951 100644 --- a/www/app/(app)/transcripts/[transcriptId]/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx @@ -86,7 +86,7 @@ export default function TranscriptDetails(details: TranscriptDetails) { useActiveTopic={useActiveTopic} waveform={waveform.waveform} media={mp3.media} - mediaDuration={transcript.response.duration} + mediaDuration={transcript.response?.duration || null} /> ) : !mp3.loading && (waveform.error || mp3.error) ? ( @@ -116,7 +116,7 @@ export default function TranscriptDetails(details: TranscriptDetails) { { transcript.reload(); diff --git a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx index 3a13052e..567272ff 100644 --- a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx @@ -24,10 +24,16 @@ const TranscriptUpload = (details: TranscriptUpload) => { const router = useRouter(); - const [status, setStatus] = useState( + const [status_, setStatus] = useState( webSockets.status.value || transcript.response?.status || "idle", ); + // status is obviously done if we have transcript + const status = + !transcript.loading && transcript.response?.status === "ended" + ? transcript.response?.status + : status_; + useEffect(() => { if (!transcriptStarted && webSockets.transcriptTextLive.length !== 0) setTranscriptStarted(true); @@ -35,8 +41,11 @@ const TranscriptUpload = (details: TranscriptUpload) => { useEffect(() => { //TODO HANDLE ERROR STATUS BETTER + // TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib const newStatus = - webSockets.status.value || transcript.response?.status || "idle"; + transcript.response?.status === "ended" + ? "ended" + : webSockets.status.value || transcript.response?.status || "idle"; setStatus(newStatus); if (newStatus && (newStatus == "ended" || newStatus == "error")) { console.log(newStatus, "redirecting"); diff --git a/www/app/(app)/transcripts/createTranscript.ts b/www/app/(app)/transcripts/createTranscript.ts index 015c82de..8a235161 100644 --- a/www/app/(app)/transcripts/createTranscript.ts +++ b/www/app/(app)/transcripts/createTranscript.ts @@ -1,45 +1,33 @@ -import { useEffect, useState } from "react"; +import type { components } from "../../reflector-api"; +import { useTranscriptCreate } from "../../lib/apiHooks"; -import { useError } from "../../(errors)/errorContext"; -import { CreateTranscript, GetTranscript } from "../../api"; -import useApi from "../../lib/useApi"; +type CreateTranscript = components["schemas"]["CreateTranscript"]; +type GetTranscript = components["schemas"]["GetTranscript"]; type UseCreateTranscript = { transcript: GetTranscript | null; loading: boolean; error: Error | null; - create: (transcriptCreationDetails: CreateTranscript) => void; + create: (transcriptCreationDetails: CreateTranscript) => Promise; }; const useCreateTranscript = (): UseCreateTranscript => { - const [transcript, setTranscript] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); + const createMutation = useTranscriptCreate(); - const create = (transcriptCreationDetails: CreateTranscript) => { - if (loading || !api) return; + const create = async (transcriptCreationDetails: CreateTranscript) => { + if (createMutation.isPending) return; - setLoading(true); - - api - .v1TranscriptsCreate({ requestBody: transcriptCreationDetails }) - .then((transcript) => { - setTranscript(transcript); - setLoading(false); - }) - .catch((err) => { - setError( - err, - "There was an issue creating a transcript, please try again.", - ); - setErrorState(err); - setLoading(false); - }); + await createMutation.mutateAsync({ + body: transcriptCreationDetails, + }); }; - return { transcript, loading, error, create }; + return { + transcript: createMutation.data || null, + loading: createMutation.isPending, + error: createMutation.error as Error | null, + create, + }; }; export default useCreateTranscript; diff --git a/www/app/(app)/transcripts/fileUploadButton.tsx b/www/app/(app)/transcripts/fileUploadButton.tsx index 1b4101e8..1f5d72eb 100644 --- a/www/app/(app)/transcripts/fileUploadButton.tsx +++ b/www/app/(app)/transcripts/fileUploadButton.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; -import useApi from "../../lib/useApi"; +import { useTranscriptUploadAudio } from "../../lib/apiHooks"; import { Button, Spinner } from "@chakra-ui/react"; +import { useError } from "../../(errors)/errorContext"; type FileUploadButton = { transcriptId: string; @@ -8,13 +9,16 @@ type FileUploadButton = { export default function FileUploadButton(props: FileUploadButton) { const fileInputRef = React.useRef(null); - const api = useApi(); + const uploadMutation = useTranscriptUploadAudio(); + const { setError } = useError(); const [progress, setProgress] = useState(0); const triggerFileUpload = () => { fileInputRef.current?.click(); }; - const handleFileUpload = (event: React.ChangeEvent) => { + const handleFileUpload = async ( + event: React.ChangeEvent, + ) => { const file = event.target.files?.[0]; if (file) { @@ -24,37 +28,45 @@ export default function FileUploadButton(props: FileUploadButton) { let start = 0; let uploadedSize = 0; - api?.httpRequest.config.interceptors.request.use((request) => { - request.onUploadProgress = (progressEvent) => { - const currentProgress = Math.floor( - ((uploadedSize + progressEvent.loaded) / file.size) * 100, - ); - setProgress(currentProgress); - }; - return request; - }); - const uploadNextChunk = async () => { - if (chunkNumber == totalChunks) return; + if (chunkNumber == totalChunks) { + setProgress(0); + return; + } const chunkSize = Math.min(maxChunkSize, file.size - start); const end = start + chunkSize; const chunk = file.slice(start, end); - await api?.v1TranscriptRecordUpload({ - transcriptId: props.transcriptId, - formData: { - chunk, - }, - chunkNumber, - totalChunks, - }); + try { + const formData = new FormData(); + formData.append("chunk", chunk); - uploadedSize += chunkSize; - chunkNumber++; - start = end; + await uploadMutation.mutateAsync({ + params: { + path: { + transcript_id: props.transcriptId, + }, + query: { + chunk_number: chunkNumber, + total_chunks: totalChunks, + }, + }, + body: formData as any, + }); - uploadNextChunk(); + uploadedSize += chunkSize; + const currentProgress = Math.floor((uploadedSize / file.size) * 100); + setProgress(currentProgress); + + chunkNumber++; + start = end; + + await uploadNextChunk(); + } catch (error) { + setError(error as Error, "Failed to upload file"); + setProgress(0); + } }; uploadNextChunk(); diff --git a/www/app/(app)/transcripts/new/page.tsx b/www/app/(app)/transcripts/new/page.tsx index 2670fd39..0410bd97 100644 --- a/www/app/(app)/transcripts/new/page.tsx +++ b/www/app/(app)/transcripts/new/page.tsx @@ -7,36 +7,29 @@ import About from "../../../(aboutAndPrivacy)/about"; import Privacy from "../../../(aboutAndPrivacy)/privacy"; import { useRouter } from "next/navigation"; import useCreateTranscript from "../createTranscript"; -import { SourceKind } from "../../../api"; import SelectSearch from "react-select-search"; import { supportedLanguages } from "../../../supportedLanguages"; -import useSessionStatus from "../../../lib/useSessionStatus"; import { featureEnabled } from "../../../domainContext"; -import { signIn } from "next-auth/react"; import { Flex, Box, Spinner, Heading, Button, - Card, Center, - Link, - CardBody, - Stack, Text, - Icon, - Grid, - IconButton, Spacer, - Menu, - Tooltip, - Input, } from "@chakra-ui/react"; +import { useAuth } from "../../../lib/AuthProvider"; +import type { components } from "../../../reflector-api"; + const TranscriptCreate = () => { const isClient = typeof window !== "undefined"; const router = useRouter(); - const { isLoading, isAuthenticated } = useSessionStatus(); + const auth = useAuth(); + const isAuthenticated = auth.status === "authenticated"; + const isAuthRefreshing = auth.status === "refreshing"; + const isLoading = auth.status === "loading"; const requireLogin = featureEnabled("requireLogin"); const [name, setName] = useState(""); @@ -55,27 +48,31 @@ const TranscriptCreate = () => { const [loadingUpload, setLoadingUpload] = useState(false); const getTargetLanguage = () => { - if (targetLanguage === "NOTRANSLATION") return; + if (targetLanguage === "NOTRANSLATION") return undefined; return targetLanguage; }; const send = () => { if (loadingRecord || createTranscript.loading || permissionDenied) return; setLoadingRecord(true); + const targetLang = getTargetLanguage(); createTranscript.create({ name, - target_language: getTargetLanguage(), - source_kind: "live" as SourceKind, + source_language: "en", + target_language: targetLang || "en", + source_kind: "live", }); }; const uploadFile = () => { if (loadingUpload || createTranscript.loading || permissionDenied) return; setLoadingUpload(true); + const targetLang = getTargetLanguage(); createTranscript.create({ name, - target_language: getTargetLanguage(), - source_kind: "file" as SourceKind, + source_language: "en", + target_language: targetLang || "en", + source_kind: "file", }); }; @@ -141,8 +138,8 @@ const TranscriptCreate = () => {
{isLoading ? ( - ) : requireLogin && !isAuthenticated ? ( - + ) : requireLogin && !isAuthenticated && !isAuthRefreshing ? ( + ) : ( { - if (!api) - throw new Error("ShareLink's API should always be ready at this point"); - const selectedOption = shareOptionsData.find( (option) => option.value === selectedValue, ); @@ -67,19 +66,27 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { share_mode: selectedValue as "public" | "semi-private" | "private", }; - const updatedTranscript = await api.v1TranscriptUpdate({ - transcriptId: props.transcriptResponse.id, - requestBody, - }); - setShareMode( - shareOptionsData.find( - (option) => option.value === updatedTranscript.share_mode, - ) || shareOptionsData[0], - ); - setShareLoading(false); + try { + const updatedTranscript = await updateTranscriptMutation.mutateAsync({ + params: { + path: { transcript_id: props.transcriptResponse.id }, + }, + body: requestBody, + }); + setShareMode( + shareOptionsData.find( + (option) => option.value === updatedTranscript.share_mode, + ) || shareOptionsData[0], + ); + } catch (err) { + console.error("Failed to update share mode:", err); + } finally { + setShareLoading(false); + } }; - const userId = useSessionUser().id; + const auth = useAuth(); + const userId = auth.status === "authenticated" ? auth.user?.id : null; useEffect(() => { setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id)); @@ -124,7 +131,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { "This transcript is public. Everyone can access it."} - {isOwner && api && ( + {isOwner && ( (undefined); + const [selectedStreamId, setSelectedStreamId] = useState(null); const [topic, setTopic] = useState(undefined); const [includeTopics, setIncludeTopics] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [streams, setStreams] = useState([]); - const [topics, setTopics] = useState([]); - const api = useApi(); + + const { data: streams = [], isLoading: isLoadingStreams } = useZulipStreams(); + const { data: topics = [] } = useZulipTopics(selectedStreamId); + const postToZulipMutation = useTranscriptPostToZulip(); + const { contains } = useFilter({ sensitivity: "base" }); - const { - collection: streamItemsCollection, - filter: streamItemsFilter, - set: streamItemsSet, - } = useListCollection({ - initialItems: [] as { label: string; value: string }[], - filter: contains, - }); + const streamItems = useMemo(() => { + return streams.map((stream: Stream) => ({ + label: stream.name, + value: stream.name, + })); + }, [streams]); - const { - collection: topicItemsCollection, - filter: topicItemsFilter, - set: topicItemsSet, - } = useListCollection({ - initialItems: [] as { label: string; value: string }[], - filter: contains, - }); + const topicItems = useMemo(() => { + return topics.map(({ name }) => ({ + label: name, + value: name, + })); + }, [topics]); + const { collection: streamItemsCollection, filter: streamItemsFilter } = + useListCollection({ + initialItems: streamItems, + filter: contains, + }); + + const { collection: topicItemsCollection, filter: topicItemsFilter } = + useListCollection({ + initialItems: topicItems, + filter: contains, + }); + + // Update selected stream ID when stream changes useEffect(() => { - const fetchZulipStreams = async () => { - if (!api) return; - - try { - const response = await api.v1ZulipGetStreams(); - setStreams(response); - - streamItemsSet( - response.map((stream) => ({ - label: stream.name, - value: stream.name, - })), - ); - - setIsLoading(false); - } catch (error) { - console.error("Error fetching Zulip streams:", error); - } - }; - - fetchZulipStreams(); - }, [!api]); - - useEffect(() => { - const fetchZulipTopics = async () => { - if (!api || !stream) return; - try { - const selectedStream = streams.find((s) => s.name === stream); - if (selectedStream) { - const response = await api.v1ZulipGetTopics({ - streamId: selectedStream.stream_id, - }); - setTopics(response); - topicItemsSet( - response.map((topic) => ({ - label: topic.name, - value: topic.name, - })), - ); - } else { - topicItemsSet([]); - } - } catch (error) { - console.error("Error fetching Zulip topics:", error); - } - }; - - fetchZulipTopics(); - }, [stream, streams, api]); + if (stream && streams) { + const selectedStream = streams.find((s: Stream) => s.name === stream); + setSelectedStreamId(selectedStream ? selectedStream.stream_id : null); + } else { + setSelectedStreamId(null); + } + }, [stream, streams]); const handleSendToZulip = async () => { - if (!api || !props.transcriptResponse) return; + if (!props.transcriptResponse) return; if (stream && topic) { try { - await api.v1TranscriptPostToZulip({ - transcriptId: props.transcriptResponse.id, - stream, - topic, - includeTopics, + await postToZulipMutation.mutateAsync({ + params: { + path: { + transcript_id: props.transcriptResponse.id, + }, + query: { + stream, + topic, + include_topics: includeTopics, + }, + }, }); setShowModal(false); } catch (error) { - console.log(error); + console.error("Error posting to Zulip:", error); } } }; @@ -155,7 +132,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) { - {isLoading ? ( + {isLoadingStreams ? ( diff --git a/www/app/(app)/transcripts/transcriptTitle.tsx b/www/app/(app)/transcripts/transcriptTitle.tsx index 4678818f..72421f48 100644 --- a/www/app/(app)/transcripts/transcriptTitle.tsx +++ b/www/app/(app)/transcripts/transcriptTitle.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; -import { UpdateTranscript } from "../../api"; -import useApi from "../../lib/useApi"; +import type { components } from "../../reflector-api"; + +type UpdateTranscript = components["schemas"]["UpdateTranscript"]; +import { useTranscriptUpdate } from "../../lib/apiHooks"; import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react"; import { LuPen } from "react-icons/lu"; @@ -14,24 +16,27 @@ const TranscriptTitle = (props: TranscriptTitle) => { const [displayedTitle, setDisplayedTitle] = useState(props.title); const [preEditTitle, setPreEditTitle] = useState(props.title); const [isEditing, setIsEditing] = useState(false); - const api = useApi(); + const updateTranscriptMutation = useTranscriptUpdate(); const updateTitle = async (newTitle: string, transcriptId: string) => { - if (!api) return; try { const requestBody: UpdateTranscript = { title: newTitle, }; - const updatedTranscript = await api?.v1TranscriptUpdate({ - transcriptId, - requestBody, + await updateTranscriptMutation.mutateAsync({ + params: { + path: { transcript_id: transcriptId }, + }, + body: requestBody, }); if (props.onUpdate) { props.onUpdate(newTitle); } - console.log("Updated transcript:", updatedTranscript); + console.log("Updated transcript title:", newTitle); } catch (err) { console.error("Failed to update transcript:", err); + // Revert title on error + setDisplayedTitle(preEditTitle); } }; diff --git a/www/app/(app)/transcripts/useMp3.ts b/www/app/(app)/transcripts/useMp3.ts index 3e8344ad..223a9a4a 100644 --- a/www/app/(app)/transcripts/useMp3.ts +++ b/www/app/(app)/transcripts/useMp3.ts @@ -1,6 +1,7 @@ import { useContext, useEffect, useState } from "react"; import { DomainContext } from "../../domainContext"; -import getApi from "../../lib/useApi"; +import { useTranscriptGet } from "../../lib/apiHooks"; +import { useAuth } from "../../lib/AuthProvider"; export type Mp3Response = { media: HTMLMediaElement | null; @@ -17,14 +18,17 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { const [audioLoadingError, setAudioLoadingError] = useState( null, ); - const [transcriptMetadataLoading, setTranscriptMetadataLoading] = - useState(true); - const [transcriptMetadataLoadingError, setTranscriptMetadataLoadingError] = - useState(null); const [audioDeleted, setAudioDeleted] = useState(null); - const api = getApi(); const { api_url } = useContext(DomainContext); - const accessTokenInfo = api?.httpRequest?.config?.TOKEN; + const auth = useAuth(); + const accessTokenInfo = + auth.status === "authenticated" ? auth.accessToken : null; + + const { + data: transcript, + isLoading: transcriptMetadataLoading, + error: transcriptError, + } = useTranscriptGet(later ? null : transcriptId); const [serviceWorker, setServiceWorker] = useState(null); @@ -52,72 +56,50 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { }, [navigator.serviceWorker, !serviceWorker, accessTokenInfo]); useEffect(() => { - if (!transcriptId || !api || later) return; + if (!transcriptId || later || !transcript) return; let stopped = false; let audioElement: HTMLAudioElement | null = null; let handleCanPlay: (() => void) | null = null; let handleError: (() => void) | null = null; - setTranscriptMetadataLoading(true); setAudioLoading(true); - // First fetch transcript info to check if audio is deleted - api - .v1TranscriptGet({ transcriptId }) - .then((transcript) => { - if (stopped) { - return; - } + const deleted = transcript.audio_deleted || false; + setAudioDeleted(deleted); - const deleted = transcript.audio_deleted || false; - setAudioDeleted(deleted); - setTranscriptMetadataLoadingError(null); + if (deleted) { + // Audio is deleted, don't attempt to load it + setMedia(null); + setAudioLoadingError(null); + setAudioLoading(false); + return; + } - if (deleted) { - // Audio is deleted, don't attempt to load it - setMedia(null); - setAudioLoadingError(null); - setAudioLoading(false); - return; - } + // Audio is not deleted, proceed to load it + audioElement = document.createElement("audio"); + audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`; + audioElement.crossOrigin = "anonymous"; + audioElement.preload = "auto"; - // Audio is not deleted, proceed to load it - audioElement = document.createElement("audio"); - audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`; - audioElement.crossOrigin = "anonymous"; - audioElement.preload = "auto"; + handleCanPlay = () => { + if (stopped) return; + setAudioLoading(false); + setAudioLoadingError(null); + }; - handleCanPlay = () => { - if (stopped) return; - setAudioLoading(false); - setAudioLoadingError(null); - }; + handleError = () => { + if (stopped) return; + setAudioLoading(false); + setAudioLoadingError("Failed to load audio"); + }; - handleError = () => { - if (stopped) return; - setAudioLoading(false); - setAudioLoadingError("Failed to load audio"); - }; + audioElement.addEventListener("canplay", handleCanPlay); + audioElement.addEventListener("error", handleError); - audioElement.addEventListener("canplay", handleCanPlay); - audioElement.addEventListener("error", handleError); - - if (!stopped) { - setMedia(audioElement); - } - }) - .catch((error) => { - if (stopped) return; - console.error("Failed to fetch transcript:", error); - setAudioDeleted(null); - setTranscriptMetadataLoadingError(error.message); - setAudioLoading(false); - }) - .finally(() => { - if (stopped) return; - setTranscriptMetadataLoading(false); - }); + if (!stopped) { + setMedia(audioElement); + } return () => { stopped = true; @@ -128,14 +110,18 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => { if (handleError) audioElement.removeEventListener("error", handleError); } }; - }, [transcriptId, api, later, api_url]); + }, [transcriptId, transcript, later, api_url]); const getNow = () => { setLater(false); }; const loading = audioLoading || transcriptMetadataLoading; - const error = audioLoadingError || transcriptMetadataLoadingError; + const error = + audioLoadingError || + (transcriptError + ? (transcriptError as any).message || String(transcriptError) + : null); return { media, loading, error, getNow, audioDeleted }; }; diff --git a/www/app/(app)/transcripts/useParticipants.ts b/www/app/(app)/transcripts/useParticipants.ts index 38f5aa35..a3674597 100644 --- a/www/app/(app)/transcripts/useParticipants.ts +++ b/www/app/(app)/transcripts/useParticipants.ts @@ -1,8 +1,6 @@ -import { useEffect, useState } from "react"; -import { Participant } from "../../api"; -import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { shouldShowError } from "../../lib/errorUtils"; +import type { components } from "../../reflector-api"; +type Participant = components["schemas"]["Participant"]; +import { useTranscriptParticipants } from "../../lib/apiHooks"; type ErrorParticipants = { error: Error; @@ -29,46 +27,38 @@ export type UseParticipants = ( ) & { refetch: () => void }; const useParticipants = (transcriptId: string): UseParticipants => { - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); - const [count, setCount] = useState(0); + const { + data: response, + isLoading: loading, + error, + refetch, + } = useTranscriptParticipants(transcriptId || null); - const refetch = () => { - if (!loading) { - setCount(count + 1); - setLoading(true); - setErrorState(null); - } - }; + // Type-safe return based on state + if (error) { + return { + error: error as Error, + loading: false, + response: null, + refetch, + } satisfies ErrorParticipants & { refetch: () => void }; + } - useEffect(() => { - if (!transcriptId || !api) return; + if (loading || !response) { + return { + response: response || null, + loading: true, + error: null, + refetch, + } satisfies LoadingParticipants & { refetch: () => void }; + } - setLoading(true); - api - .v1TranscriptGetParticipants({ transcriptId }) - .then((result) => { - setResponse(result); - setLoading(false); - console.debug("Participants Loaded:", result); - }) - .catch((error) => { - const shouldShowHuman = shouldShowError(error); - if (shouldShowHuman) { - setError(error, "There was an error loading the participants"); - } else { - setError(error); - } - setErrorState(error); - setResponse(null); - setLoading(false); - }); - }, [transcriptId, !api, count]); - - return { response, loading, error, refetch } as UseParticipants; + return { + response, + loading: false, + error: null, + refetch, + } satisfies SuccessParticipants & { refetch: () => void }; }; export default useParticipants; diff --git a/www/app/(app)/transcripts/useSearchTranscripts.ts b/www/app/(app)/transcripts/useSearchTranscripts.ts deleted file mode 100644 index 2e6a7311..00000000 --- a/www/app/(app)/transcripts/useSearchTranscripts.ts +++ /dev/null @@ -1,123 +0,0 @@ -// this hook is not great, we want to substitute it with a proper state management solution that is also not re-invention - -import { useEffect, useRef, useState } from "react"; -import { SearchResult, SourceKind } from "../../api"; -import useApi from "../../lib/useApi"; -import { - PaginationPage, - paginationPageTo0Based, -} from "../browse/_components/Pagination"; - -interface SearchFilters { - roomIds: readonly string[] | null; - sourceKind: SourceKind | null; -} - -const EMPTY_SEARCH_FILTERS: SearchFilters = { - roomIds: null, - sourceKind: null, -}; - -type UseSearchTranscriptsOptions = { - pageSize: number; - page: PaginationPage; -}; - -interface UseSearchTranscriptsReturn { - results: SearchResult[]; - totalCount: number; - isLoading: boolean; - error: unknown; - reload: () => void; -} - -function hashEffectFilters(filters: SearchFilters): string { - return JSON.stringify(filters); -} - -export function useSearchTranscripts( - query: string = "", - filters: SearchFilters = EMPTY_SEARCH_FILTERS, - options: UseSearchTranscriptsOptions = { - pageSize: 20, - page: PaginationPage(1), - }, -): UseSearchTranscriptsReturn { - const { pageSize, page } = options; - - const [reloadCount, setReloadCount] = useState(0); - - const api = useApi(); - const abortControllerRef = useRef(); - - const [data, setData] = useState<{ results: SearchResult[]; total: number }>({ - results: [], - total: 0, - }); - const [error, setError] = useState(); - const [isLoading, setIsLoading] = useState(false); - - const filterHash = hashEffectFilters(filters); - - useEffect(() => { - if (!api) { - setData({ results: [], total: 0 }); - setError(undefined); - setIsLoading(false); - return; - } - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - const performSearch = async () => { - setIsLoading(true); - - try { - const response = await api.v1TranscriptsSearch({ - q: query || "", - limit: pageSize, - offset: paginationPageTo0Based(page) * pageSize, - roomId: filters.roomIds?.[0], - sourceKind: filters.sourceKind || undefined, - }); - - if (abortController.signal.aborted) return; - setData(response); - setError(undefined); - } catch (err: unknown) { - if ((err as Error).name === "AbortError") { - return; - } - if (abortController.signal.aborted) { - console.error("Aborted search but error", err); - return; - } - - setError(err); - } finally { - if (!abortController.signal.aborted) { - setIsLoading(false); - } - } - }; - - performSearch().then(() => {}); - - return () => { - abortController.abort(); - }; - }, [api, query, page, filterHash, pageSize, reloadCount]); - - return { - results: data.results, - totalCount: data.total, - isLoading, - error, - reload: () => setReloadCount(reloadCount + 1), - }; -} diff --git a/www/app/(app)/transcripts/useTopicWithWords.ts b/www/app/(app)/transcripts/useTopicWithWords.ts index 29d0b982..31e184cc 100644 --- a/www/app/(app)/transcripts/useTopicWithWords.ts +++ b/www/app/(app)/transcripts/useTopicWithWords.ts @@ -1,9 +1,8 @@ -import { useEffect, useState } from "react"; +import type { components } from "../../reflector-api"; +import { useTranscriptTopicsWithWordsPerSpeaker } from "../../lib/apiHooks"; -import { GetTranscriptTopicWithWordsPerSpeaker } from "../../api"; -import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { shouldShowError } from "../../lib/errorUtils"; +type GetTranscriptTopicWithWordsPerSpeaker = + components["schemas"]["GetTranscriptTopicWithWordsPerSpeaker"]; type ErrorTopicWithWords = { error: Error; @@ -33,47 +32,40 @@ const useTopicWithWords = ( topicId: string | undefined, transcriptId: string, ): UseTopicWithWords => { - const [response, setResponse] = - useState(null); - const [loading, setLoading] = useState(false); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); + const { + data: response, + isLoading: loading, + error, + refetch, + } = useTranscriptTopicsWithWordsPerSpeaker( + transcriptId || null, + topicId || null, + ); - const [count, setCount] = useState(0); + if (error) { + return { + error: error as Error, + loading: false, + response: null, + refetch, + } satisfies ErrorTopicWithWords & { refetch: () => void }; + } - const refetch = () => { - if (!loading) { - setCount(count + 1); - setLoading(true); - setErrorState(null); - } - }; + if (loading || !response) { + return { + response: response || null, + loading: true, + error: false, + refetch, + } satisfies LoadingTopicWithWords & { refetch: () => void }; + } - useEffect(() => { - if (!transcriptId || !topicId || !api) return; - - setLoading(true); - - api - .v1TranscriptGetTopicsWithWordsPerSpeaker({ transcriptId, topicId }) - .then((result) => { - setResponse(result); - setLoading(false); - console.debug("Topics with words Loaded:", result); - }) - .catch((error) => { - const shouldShowHuman = shouldShowError(error); - if (shouldShowHuman) { - setError(error, "There was an error loading the topics with words"); - } else { - setError(error); - } - setErrorState(error); - }); - }, [transcriptId, !api, topicId, count]); - - return { response, loading, error, refetch } as UseTopicWithWords; + return { + response, + loading: false, + error: null, + refetch, + } satisfies SuccessTopicWithWords & { refetch: () => void }; }; export default useTopicWithWords; diff --git a/www/app/(app)/transcripts/useTopics.ts b/www/app/(app)/transcripts/useTopics.ts index ff17beaf..7f337582 100644 --- a/www/app/(app)/transcripts/useTopics.ts +++ b/www/app/(app)/transcripts/useTopics.ts @@ -1,10 +1,7 @@ -import { useEffect, useState } from "react"; +import { useTranscriptTopics } from "../../lib/apiHooks"; +import type { components } from "../../reflector-api"; -import { useError } from "../../(errors)/errorContext"; -import { Topic } from "./webSocketTypes"; -import useApi from "../../lib/useApi"; -import { shouldShowError } from "../../lib/errorUtils"; -import { GetTranscriptTopic } from "../../api"; +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; type TranscriptTopics = { topics: GetTranscriptTopic[] | null; @@ -13,34 +10,13 @@ type TranscriptTopics = { }; const useTopics = (id: string): TranscriptTopics => { - const [topics, setTopics] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); - useEffect(() => { - if (!id || !api) return; + const { data: topics, isLoading: loading, error } = useTranscriptTopics(id); - setLoading(true); - api - .v1TranscriptGetTopics({ transcriptId: id }) - .then((result) => { - setTopics(result); - setLoading(false); - console.debug("Transcript topics loaded:", result); - }) - .catch((err) => { - setErrorState(err); - const shouldShowHuman = shouldShowError(err); - if (shouldShowHuman) { - setError(err, "There was an error loading the topics"); - } else { - setError(err); - } - }); - }, [id, !api]); - - return { topics, loading, error }; + return { + topics: topics || null, + loading, + error: error as Error | null, + }; }; export default useTopics; diff --git a/www/app/(app)/transcripts/useTranscript.ts b/www/app/(app)/transcripts/useTranscript.ts index 49d257f0..3e56fb9e 100644 --- a/www/app/(app)/transcripts/useTranscript.ts +++ b/www/app/(app)/transcripts/useTranscript.ts @@ -1,8 +1,7 @@ -import { useEffect, useState } from "react"; -import { GetTranscript } from "../../api"; -import { useError } from "../../(errors)/errorContext"; -import { shouldShowError } from "../../lib/errorUtils"; -import useApi from "../../lib/useApi"; +import type { components } from "../../reflector-api"; +import { useTranscriptGet } from "../../lib/apiHooks"; + +type GetTranscript = components["schemas"]["GetTranscript"]; type ErrorTranscript = { error: Error; @@ -28,43 +27,43 @@ type SuccessTranscript = { const useTranscript = ( id: string | null, ): ErrorTranscript | LoadingTranscript | SuccessTranscript => { - const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); - const [reload, setReload] = useState(0); - const { setError } = useError(); - const api = useApi(); - const reloadHandler = () => setReload((prev) => prev + 1); + const { data, isLoading, error, refetch } = useTranscriptGet(id); - useEffect(() => { - if (!id || !api) return; + // Map to the expected return format + if (isLoading) { + return { + response: null, + loading: true, + error: false, + reload: refetch, + }; + } - if (!response) { - setLoading(true); - } + if (error) { + return { + error: error as Error, + loading: false, + response: null, + reload: refetch, + }; + } - api - .v1TranscriptGet({ transcriptId: id }) - .then((result) => { - setResponse(result); - setLoading(false); - console.debug("Transcript Loaded:", result); - }) - .catch((error) => { - const shouldShowHuman = shouldShowError(error); - if (shouldShowHuman) { - setError(error, "There was an error loading the transcript"); - } else { - setError(error); - } - setErrorState(error); - }); - }, [id, !api, reload]); + // Check if data is undefined or null + if (!data) { + return { + response: null, + loading: true, + error: false, + reload: refetch, + }; + } - return { response, loading, error, reload: reloadHandler } as - | ErrorTranscript - | LoadingTranscript - | SuccessTranscript; + return { + response: data, + loading: false, + error: null, + reload: refetch, + }; }; export default useTranscript; diff --git a/www/app/(app)/transcripts/useWaveform.ts b/www/app/(app)/transcripts/useWaveform.ts index 19b2a265..8bb8c4c9 100644 --- a/www/app/(app)/transcripts/useWaveform.ts +++ b/www/app/(app)/transcripts/useWaveform.ts @@ -1,8 +1,7 @@ -import { useEffect, useState } from "react"; -import { AudioWaveform } from "../../api"; -import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { shouldShowError } from "../../lib/errorUtils"; +import type { components } from "../../reflector-api"; +import { useTranscriptWaveform } from "../../lib/apiHooks"; + +type AudioWaveform = components["schemas"]["AudioWaveform"]; type AudioWaveFormResponse = { waveform: AudioWaveform | null; @@ -11,35 +10,17 @@ type AudioWaveFormResponse = { }; const useWaveform = (id: string, skip: boolean): AudioWaveFormResponse => { - const [waveform, setWaveform] = useState(null); - const [loading, setLoading] = useState(false); - const [error, setErrorState] = useState(null); - const { setError } = useError(); - const api = useApi(); + const { + data: waveform, + isLoading: loading, + error, + } = useTranscriptWaveform(skip ? null : id); - useEffect(() => { - if (!id || !api || skip) { - setLoading(false); - setErrorState(null); - setWaveform(null); - return; - } - setLoading(true); - setErrorState(null); - api - .v1TranscriptGetAudioWaveform({ transcriptId: id }) - .then((result) => { - setWaveform(result); - setLoading(false); - console.debug("Transcript waveform loaded:", result); - }) - .catch((err) => { - setErrorState(err); - setLoading(false); - }); - }, [id, api, skip]); - - return { waveform, loading, error }; + return { + waveform: waveform || null, + loading, + error: error as Error | null, + }; }; export default useWaveform; diff --git a/www/app/(app)/transcripts/useWebRTC.ts b/www/app/(app)/transcripts/useWebRTC.ts index c8370aa4..89a2a946 100644 --- a/www/app/(app)/transcripts/useWebRTC.ts +++ b/www/app/(app)/transcripts/useWebRTC.ts @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; import Peer from "simple-peer"; import { useError } from "../../(errors)/errorContext"; -import useApi from "../../lib/useApi"; -import { RtcOffer } from "../../api"; +import { useTranscriptWebRTC } from "../../lib/apiHooks"; +import type { components } from "../../reflector-api"; +type RtcOffer = components["schemas"]["RtcOffer"]; const useWebRTC = ( stream: MediaStream | null, @@ -10,10 +11,10 @@ const useWebRTC = ( ): Peer => { const [peer, setPeer] = useState(null); const { setError } = useError(); - const api = useApi(); + const { mutateAsync: mutateWebRtcTranscriptAsync } = useTranscriptWebRTC(); useEffect(() => { - if (!stream || !transcriptId || !api) { + if (!stream || !transcriptId) { return; } @@ -24,7 +25,7 @@ const useWebRTC = ( try { p = new Peer({ initiator: true, stream: stream }); } catch (error) { - setError(error, "Error creating WebRTC"); + setError(error as Error, "Error creating WebRTC"); return; } @@ -32,26 +33,31 @@ const useWebRTC = ( setError(new Error(`WebRTC error: ${err}`)); }); - p.on("signal", (data: any) => { - if (!api) return; + p.on("signal", async (data: any) => { if ("sdp" in data) { const rtcOffer: RtcOffer = { sdp: data.sdp, type: data.type, }; - api - .v1TranscriptRecordWebrtc({ transcriptId, requestBody: rtcOffer }) - .then((answer) => { - try { - p.signal(answer); - } catch (error) { - setError(error); - } - }) - .catch((error) => { - setError(error, "Error loading WebRTCOffer"); + try { + const answer = await mutateWebRtcTranscriptAsync({ + params: { + path: { + transcript_id: transcriptId, + }, + }, + body: rtcOffer, }); + + try { + p.signal(answer); + } catch (error) { + setError(error as Error); + } + } catch (error) { + setError(error as Error, "Error loading WebRTCOffer"); + } } }); @@ -63,7 +69,7 @@ const useWebRTC = ( return () => { p.destroy(); }; - }, [stream, transcriptId, !api]); + }, [stream, transcriptId, mutateWebRtcTranscriptAsync]); return peer; }; diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts index 6fa5edc7..2b3205c4 100644 --- a/www/app/(app)/transcripts/useWebSockets.ts +++ b/www/app/(app)/transcripts/useWebSockets.ts @@ -2,8 +2,12 @@ import { useContext, useEffect, useState } from "react"; import { Topic, FinalSummary, Status } from "./webSocketTypes"; import { useError } from "../../(errors)/errorContext"; import { DomainContext } from "../../domainContext"; -import { AudioWaveform, GetTranscriptSegmentTopic } from "../../api"; -import useApi from "../../lib/useApi"; +import type { components } from "../../reflector-api"; +type AudioWaveform = components["schemas"]["AudioWaveform"]; +type GetTranscriptSegmentTopic = + components["schemas"]["GetTranscriptSegmentTopic"]; +import { useQueryClient } from "@tanstack/react-query"; +import { $api } from "../../lib/apiClient"; export type UseWebSockets = { transcriptTextLive: string; @@ -33,8 +37,8 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { const [status, setStatus] = useState({ value: "" }); const { setError } = useError(); - const { websocket_url } = useContext(DomainContext); - const api = useApi(); + const { websocket_url: websocketUrl } = useContext(DomainContext); + const queryClient = useQueryClient(); const [accumulatedText, setAccumulatedText] = useState(""); @@ -105,6 +109,13 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { title: "Topic 1: Introduction to Quantum Mechanics", transcript: "A brief overview of quantum mechanics and its principles.", + segments: [ + { + speaker: 1, + start: 0, + text: "This is the transcription of an example title", + }, + ], }, { id: "2", @@ -315,11 +326,9 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { } }; - if (!transcriptId || !api) return; + if (!transcriptId) return; - api?.v1TranscriptGetWebsocketEvents({ transcriptId }).then((result) => {}); - - const url = `${websocket_url}/v1/transcripts/${transcriptId}/events`; + const url = `${websocketUrl}/v1/transcripts/${transcriptId}/events`; let ws = new WebSocket(url); ws.onopen = () => { @@ -361,6 +370,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { return [...prevTopics, topic]; }); console.debug("TOPIC event:", message.data); + // Invalidate topics query to sync with WebSocket data + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/topics", + { + params: { path: { transcript_id: transcriptId } }, + }, + ).queryKey, + }); break; case "FINAL_SHORT_SUMMARY": @@ -370,6 +389,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { case "FINAL_LONG_SUMMARY": if (message.data) { setFinalSummary(message.data); + // Invalidate transcript query to sync summary + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { path: { transcript_id: transcriptId } }, + }, + ).queryKey, + }); } break; @@ -377,6 +406,16 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { console.debug("FINAL_TITLE event:", message.data); if (message.data) { setTitle(message.data.title); + // Invalidate transcript query to sync title + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { path: { transcript_id: transcriptId } }, + }, + ).queryKey, + }); } break; @@ -434,6 +473,11 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { break; case 1001: // Navigate away break; + case 1006: // Closed by client Chrome + console.warn( + "WebSocket closed by client, likely duplicated connection in react dev mode", + ); + break; default: setError( new Error(`WebSocket closed unexpectedly with code: ${event.code}`), @@ -450,7 +494,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => { return () => { ws.close(); }; - }, [transcriptId, !api]); + }, [transcriptId, websocketUrl]); return { transcriptTextLive, diff --git a/www/app/(app)/transcripts/webSocketTypes.ts b/www/app/(app)/transcripts/webSocketTypes.ts index edd35eb6..4ec98946 100644 --- a/www/app/(app)/transcripts/webSocketTypes.ts +++ b/www/app/(app)/transcripts/webSocketTypes.ts @@ -1,4 +1,6 @@ -import { GetTranscriptTopic } from "../../api"; +import type { components } from "../../reflector-api"; + +type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; export type Topic = GetTranscriptTopic; diff --git a/www/app/(auth)/userInfo.tsx b/www/app/(auth)/userInfo.tsx index ffb286b3..bf6a5b62 100644 --- a/www/app/(auth)/userInfo.tsx +++ b/www/app/(auth)/userInfo.tsx @@ -1,18 +1,21 @@ "use client"; -import { signOut, signIn } from "next-auth/react"; -import useSessionStatus from "../lib/useSessionStatus"; + import { Spinner, Link } from "@chakra-ui/react"; +import { useAuth } from "../lib/AuthProvider"; export default function UserInfo() { - const { isLoading, isAuthenticated } = useSessionStatus(); - + const auth = useAuth(); + const status = auth.status; + const isLoading = status === "loading"; + const isAuthenticated = status === "authenticated"; + const isRefreshing = status === "refreshing"; return isLoading ? ( - ) : !isAuthenticated ? ( + ) : !isAuthenticated && !isRefreshing ? ( signIn("authentik")} + onClick={() => auth.signIn("authentik")} > Log in @@ -20,7 +23,7 @@ export default function UserInfo() { signOut({ callbackUrl: "/" })} + onClick={() => auth.signOut({ callbackUrl: "/" })} > Log out diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx index b03a7e4f..0130588b 100644 --- a/www/app/[roomName]/page.tsx +++ b/www/app/[roomName]/page.tsx @@ -21,11 +21,13 @@ import { toaster } from "../components/ui/toaster"; import useRoomMeeting from "./useRoomMeeting"; import { useRouter } from "next/navigation"; import { notFound } from "next/navigation"; -import useSessionStatus from "../lib/useSessionStatus"; import { useRecordingConsent } from "../recordingConsentContext"; -import useApi from "../lib/useApi"; -import { Meeting } from "../api"; +import { useMeetingAudioConsent } from "../lib/apiHooks"; +import type { components } from "../reflector-api"; + +type Meeting = components["schemas"]["Meeting"]; import { FaBars } from "react-icons/fa6"; +import { useAuth } from "../lib/AuthProvider"; export type RoomDetails = { params: { @@ -76,31 +78,30 @@ const useConsentDialog = ( wherebyRef: RefObject /*accessibility*/, ) => { const { state: consentState, touch, hasConsent } = useRecordingConsent(); - const [consentLoading, setConsentLoading] = useState(false); // toast would open duplicates, even with using "id=" prop const [modalOpen, setModalOpen] = useState(false); - const api = useApi(); + const audioConsentMutation = useMeetingAudioConsent(); const handleConsent = useCallback( async (meetingId: string, given: boolean) => { - if (!api) return; - - setConsentLoading(true); - try { - await api.v1MeetingAudioConsent({ - meetingId, - requestBody: { consent_given: given }, + await audioConsentMutation.mutateAsync({ + params: { + path: { + meeting_id: meetingId, + }, + }, + body: { + consent_given: given, + }, }); touch(meetingId); } catch (error) { console.error("Error submitting consent:", error); - } finally { - setConsentLoading(false); } }, - [api, touch], + [audioConsentMutation, touch], ); const showConsentModal = useCallback(() => { @@ -194,7 +195,12 @@ const useConsentDialog = ( return cleanup; }, [meetingId, handleConsent, wherebyRef, modalOpen]); - return { showConsentModal, consentState, hasConsent, consentLoading }; + return { + showConsentModal, + consentState, + hasConsent, + consentLoading: audioConsentMutation.isPending, + }; }; function ConsentDialogButton({ @@ -254,7 +260,9 @@ export default function Room(details: RoomDetails) { const roomName = details.params.roomName; const meeting = useRoomMeeting(roomName); const router = useRouter(); - const { isLoading, isAuthenticated } = useSessionStatus(); + const status = useAuth().status; + const isAuthenticated = status === "authenticated"; + const isLoading = status === "loading" || meeting.loading; const roomUrl = meeting?.response?.host_room_url ? meeting?.response?.host_room_url diff --git a/www/app/[roomName]/useRoomMeeting.tsx b/www/app/[roomName]/useRoomMeeting.tsx index 98c2f1f2..93491a05 100644 --- a/www/app/[roomName]/useRoomMeeting.tsx +++ b/www/app/[roomName]/useRoomMeeting.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; import { useError } from "../(errors)/errorContext"; -import { Meeting } from "../api"; +import type { components } from "../reflector-api"; import { shouldShowError } from "../lib/errorUtils"; -import useApi from "../lib/useApi"; + +type Meeting = components["schemas"]["Meeting"]; +import { useRoomsCreateMeeting } from "../lib/apiHooks"; import { notFound } from "next/navigation"; type ErrorMeeting = { @@ -30,27 +32,25 @@ const useRoomMeeting = ( roomName: string | null | undefined, ): ErrorMeeting | LoadingMeeting | SuccessMeeting => { const [response, setResponse] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setErrorState] = useState(null); const [reload, setReload] = useState(0); const { setError } = useError(); - const api = useApi(); + const createMeetingMutation = useRoomsCreateMeeting(); const reloadHandler = () => setReload((prev) => prev + 1); useEffect(() => { - if (!roomName || !api) return; + if (!roomName) return; - if (!response) { - setLoading(true); - } - - api - .v1RoomsCreateMeeting({ roomName }) - .then((result) => { + const createMeeting = async () => { + try { + const result = await createMeetingMutation.mutateAsync({ + params: { + path: { + room_name: roomName, + }, + }, + }); setResponse(result); - setLoading(false); - }) - .catch((error) => { + } catch (error: any) { const shouldShowHuman = shouldShowError(error); if (shouldShowHuman && error.status !== 404) { setError( @@ -60,9 +60,14 @@ const useRoomMeeting = ( } else { setError(error); } - setErrorState(error); - }); - }, [roomName, !api, reload]); + } + }; + + createMeeting(); + }, [roomName, reload]); + + const loading = createMeetingMutation.isPending && !response; + const error = createMeetingMutation.error as Error | null; return { response, loading, error, reload: reloadHandler } as | ErrorMeeting diff --git a/www/app/api/OpenApi.ts b/www/app/api/OpenApi.ts deleted file mode 100644 index 23cc35f3..00000000 --- a/www/app/api/OpenApi.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { BaseHttpRequest } from "./core/BaseHttpRequest"; -import type { OpenAPIConfig } from "./core/OpenAPI"; -import { Interceptors } from "./core/OpenAPI"; -import { AxiosHttpRequest } from "./core/AxiosHttpRequest"; - -import { DefaultService } from "./services.gen"; - -type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest; - -export class OpenApi { - public readonly default: DefaultService; - - public readonly request: BaseHttpRequest; - - constructor( - config?: Partial, - HttpRequest: HttpRequestConstructor = AxiosHttpRequest, - ) { - this.request = new HttpRequest({ - BASE: config?.BASE ?? "", - VERSION: config?.VERSION ?? "0.1.0", - WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false, - CREDENTIALS: config?.CREDENTIALS ?? "include", - TOKEN: config?.TOKEN, - USERNAME: config?.USERNAME, - PASSWORD: config?.PASSWORD, - HEADERS: config?.HEADERS, - ENCODE_PATH: config?.ENCODE_PATH, - interceptors: { - request: config?.interceptors?.request ?? new Interceptors(), - response: config?.interceptors?.response ?? new Interceptors(), - }, - }); - - this.default = new DefaultService(this.request); - } -} diff --git a/www/app/api/auth/[...nextauth]/route.ts b/www/app/api/auth/[...nextauth]/route.ts index 915ed04d..7b73c22a 100644 --- a/www/app/api/auth/[...nextauth]/route.ts +++ b/www/app/api/auth/[...nextauth]/route.ts @@ -1,8 +1,5 @@ -// NextAuth route handler for Authentik -// Refresh rotation has been taken from https://next-auth.js.org/v3/tutorials/refresh-token-rotation even if we are using 4.x - import NextAuth from "next-auth"; -import { authOptions } from "../../../lib/auth"; +import { authOptions } from "../../../lib/authBackend"; const handler = NextAuth(authOptions); diff --git a/www/app/api/core/ApiError.ts b/www/app/api/core/ApiError.ts deleted file mode 100644 index 1d07bb31..00000000 --- a/www/app/api/core/ApiError.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import type { ApiResult } from "./ApiResult"; - -export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: unknown; - public readonly request: ApiRequestOptions; - - constructor( - request: ApiRequestOptions, - response: ApiResult, - message: string, - ) { - super(message); - - this.name = "ApiError"; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } -} diff --git a/www/app/api/core/ApiRequestOptions.ts b/www/app/api/core/ApiRequestOptions.ts deleted file mode 100644 index 57fbb095..00000000 --- a/www/app/api/core/ApiRequestOptions.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type ApiRequestOptions = { - readonly method: - | "GET" - | "PUT" - | "POST" - | "DELETE" - | "OPTIONS" - | "HEAD" - | "PATCH"; - readonly url: string; - readonly path?: Record; - readonly cookies?: Record; - readonly headers?: Record; - readonly query?: Record; - readonly formData?: Record; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly responseTransformer?: (data: unknown) => Promise; - readonly errors?: Record; -}; diff --git a/www/app/api/core/ApiResult.ts b/www/app/api/core/ApiResult.ts deleted file mode 100644 index 05040ba8..00000000 --- a/www/app/api/core/ApiResult.ts +++ /dev/null @@ -1,7 +0,0 @@ -export type ApiResult = { - readonly body: TData; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly url: string; -}; diff --git a/www/app/api/core/AxiosHttpRequest.ts b/www/app/api/core/AxiosHttpRequest.ts deleted file mode 100644 index aba5096e..00000000 --- a/www/app/api/core/AxiosHttpRequest.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import { BaseHttpRequest } from "./BaseHttpRequest"; -import type { CancelablePromise } from "./CancelablePromise"; -import type { OpenAPIConfig } from "./OpenAPI"; -import { request as __request } from "./request"; - -export class AxiosHttpRequest extends BaseHttpRequest { - constructor(config: OpenAPIConfig) { - super(config); - } - - /** - * Request method - * @param options The request options from the service - * @returns CancelablePromise - * @throws ApiError - */ - public override request( - options: ApiRequestOptions, - ): CancelablePromise { - return __request(this.config, options); - } -} diff --git a/www/app/api/core/BaseHttpRequest.ts b/www/app/api/core/BaseHttpRequest.ts deleted file mode 100644 index 3f89861c..00000000 --- a/www/app/api/core/BaseHttpRequest.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import type { CancelablePromise } from "./CancelablePromise"; -import type { OpenAPIConfig } from "./OpenAPI"; - -export abstract class BaseHttpRequest { - constructor(public readonly config: OpenAPIConfig) {} - - public abstract request( - options: ApiRequestOptions, - ): CancelablePromise; -} diff --git a/www/app/api/core/CancelablePromise.ts b/www/app/api/core/CancelablePromise.ts deleted file mode 100644 index 0640e989..00000000 --- a/www/app/api/core/CancelablePromise.ts +++ /dev/null @@ -1,126 +0,0 @@ -export class CancelError extends Error { - constructor(message: string) { - super(message); - this.name = "CancelError"; - } - - public get isCancelled(): boolean { - return true; - } -} - -export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; - - (cancelHandler: () => void): void; -} - -export class CancelablePromise implements Promise { - private _isResolved: boolean; - private _isRejected: boolean; - private _isCancelled: boolean; - readonly cancelHandlers: (() => void)[]; - readonly promise: Promise; - private _resolve?: (value: T | PromiseLike) => void; - private _reject?: (reason?: unknown) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike) => void, - reject: (reason?: unknown) => void, - onCancel: OnCancel, - ) => void, - ) { - this._isResolved = false; - this._isRejected = false; - this._isCancelled = false; - this.cancelHandlers = []; - this.promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; - - const onResolve = (value: T | PromiseLike): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return; - } - this._isResolved = true; - if (this._resolve) this._resolve(value); - }; - - const onReject = (reason?: unknown): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return; - } - this._isRejected = true; - if (this._reject) this._reject(reason); - }; - - const onCancel = (cancelHandler: () => void): void => { - if (this._isResolved || this._isRejected || this._isCancelled) { - return; - } - this.cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, "isResolved", { - get: (): boolean => this._isResolved, - }); - - Object.defineProperty(onCancel, "isRejected", { - get: (): boolean => this._isRejected, - }); - - Object.defineProperty(onCancel, "isCancelled", { - get: (): boolean => this._isCancelled, - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return "Cancellable Promise"; - } - - public then( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, - ): Promise { - return this.promise.then(onFulfilled, onRejected); - } - - public catch( - onRejected?: ((reason: unknown) => TResult | PromiseLike) | null, - ): Promise { - return this.promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise { - return this.promise.finally(onFinally); - } - - public cancel(): void { - if (this._isResolved || this._isRejected || this._isCancelled) { - return; - } - this._isCancelled = true; - if (this.cancelHandlers.length) { - try { - for (const cancelHandler of this.cancelHandlers) { - cancelHandler(); - } - } catch (error) { - console.warn("Cancellation threw an error", error); - return; - } - } - this.cancelHandlers.length = 0; - if (this._reject) this._reject(new CancelError("Request aborted")); - } - - public get isCancelled(): boolean { - return this._isCancelled; - } -} diff --git a/www/app/api/core/OpenAPI.ts b/www/app/api/core/OpenAPI.ts deleted file mode 100644 index 20ea0ed9..00000000 --- a/www/app/api/core/OpenAPI.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { AxiosRequestConfig, AxiosResponse } from "axios"; -import type { ApiRequestOptions } from "./ApiRequestOptions"; - -type Headers = Record; -type Middleware = (value: T) => T | Promise; -type Resolver = (options: ApiRequestOptions) => Promise; - -export class Interceptors { - _fns: Middleware[]; - - constructor() { - this._fns = []; - } - - eject(fn: Middleware): void { - const index = this._fns.indexOf(fn); - if (index !== -1) { - this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; - } - } - - use(fn: Middleware): void { - this._fns = [...this._fns, fn]; - } -} - -export type OpenAPIConfig = { - BASE: string; - CREDENTIALS: "include" | "omit" | "same-origin"; - ENCODE_PATH?: ((path: string) => string) | undefined; - HEADERS?: Headers | Resolver | undefined; - PASSWORD?: string | Resolver | undefined; - TOKEN?: string | Resolver | undefined; - USERNAME?: string | Resolver | undefined; - VERSION: string; - WITH_CREDENTIALS: boolean; - interceptors: { - request: Interceptors; - response: Interceptors; - }; -}; - -export const OpenAPI: OpenAPIConfig = { - BASE: "", - CREDENTIALS: "include", - ENCODE_PATH: undefined, - HEADERS: undefined, - PASSWORD: undefined, - TOKEN: undefined, - USERNAME: undefined, - VERSION: "0.1.0", - WITH_CREDENTIALS: false, - interceptors: { - request: new Interceptors(), - response: new Interceptors(), - }, -}; diff --git a/www/app/api/core/request.ts b/www/app/api/core/request.ts deleted file mode 100644 index b576207e..00000000 --- a/www/app/api/core/request.ts +++ /dev/null @@ -1,387 +0,0 @@ -import axios from "axios"; -import type { - AxiosError, - AxiosRequestConfig, - AxiosResponse, - AxiosInstance, -} from "axios"; - -import { ApiError } from "./ApiError"; -import type { ApiRequestOptions } from "./ApiRequestOptions"; -import type { ApiResult } from "./ApiResult"; -import { CancelablePromise } from "./CancelablePromise"; -import type { OnCancel } from "./CancelablePromise"; -import type { OpenAPIConfig } from "./OpenAPI"; - -export const isString = (value: unknown): value is string => { - return typeof value === "string"; -}; - -export const isStringWithValue = (value: unknown): value is string => { - return isString(value) && value !== ""; -}; - -export const isBlob = (value: any): value is Blob => { - return value instanceof Blob; -}; - -export const isFormData = (value: unknown): value is FormData => { - return value instanceof FormData; -}; - -export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; -}; - -export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString("base64"); - } -}; - -export const getQueryString = (params: Record): string => { - const qs: string[] = []; - - const append = (key: string, value: unknown) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; - - const encodePair = (key: string, value: unknown) => { - if (value === undefined || value === null) { - return; - } - - if (value instanceof Date) { - append(key, value.toISOString()); - } else if (Array.isArray(value)) { - value.forEach((v) => encodePair(key, v)); - } else if (typeof value === "object") { - Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v)); - } else { - append(key, value); - } - }; - - Object.entries(params).forEach(([key, value]) => encodePair(key, value)); - - return qs.length ? `?${qs.join("&")}` : ""; -}; - -const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace("{api-version}", config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); - - const url = config.BASE + path; - return options.query ? url + getQueryString(options.query) : url; -}; - -export const getFormData = ( - options: ApiRequestOptions, -): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: unknown) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([, value]) => value !== undefined && value !== null) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((v) => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; -}; - -type Resolver = (options: ApiRequestOptions) => Promise; - -export const resolve = async ( - options: ApiRequestOptions, - resolver?: T | Resolver, -): Promise => { - if (typeof resolver === "function") { - return (resolver as Resolver)(options); - } - return resolver; -}; - -export const getHeaders = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, -): Promise> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - // @ts-ignore - resolve(options, config.TOKEN), - // @ts-ignore - resolve(options, config.USERNAME), - // @ts-ignore - resolve(options, config.PASSWORD), - // @ts-ignore - resolve(options, config.HEADERS), - ]); - - const headers = Object.entries({ - Accept: "application/json", - ...additionalHeaders, - ...options.headers, - }) - .filter(([, value]) => value !== undefined && value !== null) - .reduce( - (headers, [key, value]) => ({ - ...headers, - [key]: String(value), - }), - {} as Record, - ); - - if (isStringWithValue(token)) { - headers["Authorization"] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers["Authorization"] = `Basic ${credentials}`; - } - - if (options.body !== undefined) { - if (options.mediaType) { - headers["Content-Type"] = options.mediaType; - } else if (isBlob(options.body)) { - headers["Content-Type"] = options.body.type || "application/octet-stream"; - } else if (isString(options.body)) { - headers["Content-Type"] = "text/plain"; - } else if (!isFormData(options.body)) { - headers["Content-Type"] = "application/json"; - } - } else if (options.formData !== undefined) { - if (options.mediaType) { - headers["Content-Type"] = options.mediaType; - } - } - - return headers; -}; - -export const getRequestBody = (options: ApiRequestOptions): unknown => { - if (options.body) { - return options.body; - } - return undefined; -}; - -export const sendRequest = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: unknown, - formData: FormData | undefined, - headers: Record, - onCancel: OnCancel, - axiosClient: AxiosInstance, -): Promise> => { - const controller = new AbortController(); - - let requestConfig: AxiosRequestConfig = { - data: body ?? formData, - headers, - method: options.method, - signal: controller.signal, - url, - withCredentials: config.WITH_CREDENTIALS, - }; - - onCancel(() => controller.abort()); - - for (const fn of config.interceptors.request._fns) { - requestConfig = await fn(requestConfig); - } - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError; - if (axiosError.response) { - return axiosError.response; - } - throw error; - } -}; - -export const getResponseHeader = ( - response: AxiosResponse, - responseHeader?: string, -): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } - } - return undefined; -}; - -export const getResponseBody = (response: AxiosResponse): unknown => { - if (response.status !== 204) { - return response.data; - } - return undefined; -}; - -export const catchErrorCodes = ( - options: ApiRequestOptions, - result: ApiResult, -): void => { - const errors: Record = { - 400: "Bad Request", - 401: "Unauthorized", - 402: "Payment Required", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 406: "Not Acceptable", - 407: "Proxy Authentication Required", - 408: "Request Timeout", - 409: "Conflict", - 410: "Gone", - 411: "Length Required", - 412: "Precondition Failed", - 413: "Payload Too Large", - 414: "URI Too Long", - 415: "Unsupported Media Type", - 416: "Range Not Satisfiable", - 417: "Expectation Failed", - 418: "Im a teapot", - 421: "Misdirected Request", - 422: "Unprocessable Content", - 423: "Locked", - 424: "Failed Dependency", - 425: "Too Early", - 426: "Upgrade Required", - 428: "Precondition Required", - 429: "Too Many Requests", - 431: "Request Header Fields Too Large", - 451: "Unavailable For Legal Reasons", - 500: "Internal Server Error", - 501: "Not Implemented", - 502: "Bad Gateway", - 503: "Service Unavailable", - 504: "Gateway Timeout", - 505: "HTTP Version Not Supported", - 506: "Variant Also Negotiates", - 507: "Insufficient Storage", - 508: "Loop Detected", - 510: "Not Extended", - 511: "Network Authentication Required", - ...options.errors, - }; - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? "unknown"; - const errorStatusText = result.statusText ?? "unknown"; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError( - options, - result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}`, - ); - } -}; - -/** - * Request method - * @param config The OpenAPI configuration object - * @param options The request options from the service - * @param axiosClient The axios client instance to use - * @returns CancelablePromise - * @throws ApiError - */ -export const request = ( - config: OpenAPIConfig, - options: ApiRequestOptions, - axiosClient: AxiosInstance = axios, -): CancelablePromise => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options); - - if (!onCancel.isCancelled) { - let response = await sendRequest( - config, - options, - url, - body, - formData, - headers, - onCancel, - axiosClient, - ); - - for (const fn of config.interceptors.response._fns) { - response = await fn(response); - } - - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader( - response, - options.responseHeader, - ); - - let transformedBody = responseBody; - if (options.responseTransformer && isSuccess(response.status)) { - transformedBody = await options.responseTransformer(responseBody); - } - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? transformedBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; diff --git a/www/app/api/index.ts b/www/app/api/index.ts deleted file mode 100644 index 27fbb57d..00000000 --- a/www/app/api/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts -export { OpenApi } from "./OpenApi"; -export { ApiError } from "./core/ApiError"; -export { BaseHttpRequest } from "./core/BaseHttpRequest"; -export { CancelablePromise, CancelError } from "./core/CancelablePromise"; -export { OpenAPI, type OpenAPIConfig } from "./core/OpenAPI"; -export * from "./schemas.gen"; -export * from "./services.gen"; -export * from "./types.gen"; diff --git a/www/app/api/schemas.gen.ts b/www/app/api/schemas.gen.ts index 03091a5f..e69de29b 100644 --- a/www/app/api/schemas.gen.ts +++ b/www/app/api/schemas.gen.ts @@ -1,1776 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export const $AudioWaveform = { - properties: { - data: { - items: { - type: "number", - }, - type: "array", - title: "Data", - }, - }, - type: "object", - required: ["data"], - title: "AudioWaveform", -} as const; - -export const $Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post = - { - properties: { - chunk: { - type: "string", - format: "binary", - title: "Chunk", - }, - }, - type: "object", - required: ["chunk"], - title: - "Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post", - } as const; - -export const $CreateParticipant = { - properties: { - speaker: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["name"], - title: "CreateParticipant", -} as const; - -export const $CreateRoom = { - properties: { - name: { - type: "string", - title: "Name", - }, - zulip_auto_post: { - type: "boolean", - title: "Zulip Auto Post", - }, - zulip_stream: { - type: "string", - title: "Zulip Stream", - }, - zulip_topic: { - type: "string", - title: "Zulip Topic", - }, - is_locked: { - type: "boolean", - title: "Is Locked", - }, - room_mode: { - type: "string", - title: "Room Mode", - }, - recording_type: { - type: "string", - title: "Recording Type", - }, - recording_trigger: { - type: "string", - title: "Recording Trigger", - }, - is_shared: { - type: "boolean", - title: "Is Shared", - }, - webhook_url: { - type: "string", - title: "Webhook Url", - }, - webhook_secret: { - type: "string", - title: "Webhook Secret", - }, - }, - type: "object", - required: [ - "name", - "zulip_auto_post", - "zulip_stream", - "zulip_topic", - "is_locked", - "room_mode", - "recording_type", - "recording_trigger", - "is_shared", - "webhook_url", - "webhook_secret", - ], - title: "CreateRoom", -} as const; - -export const $CreateTranscript = { - properties: { - name: { - type: "string", - title: "Name", - }, - source_language: { - type: "string", - title: "Source Language", - default: "en", - }, - target_language: { - type: "string", - title: "Target Language", - default: "en", - }, - source_kind: { - anyOf: [ - { - $ref: "#/components/schemas/SourceKind", - }, - { - type: "null", - }, - ], - }, - }, - type: "object", - required: ["name"], - title: "CreateTranscript", -} as const; - -export const $DeletionStatus = { - properties: { - status: { - type: "string", - title: "Status", - }, - }, - type: "object", - required: ["status"], - title: "DeletionStatus", -} as const; - -export const $GetTranscript = { - properties: { - id: { - type: "string", - title: "Id", - }, - user_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "User Id", - }, - name: { - type: "string", - title: "Name", - }, - status: { - type: "string", - title: "Status", - }, - locked: { - type: "boolean", - title: "Locked", - }, - duration: { - type: "number", - title: "Duration", - }, - title: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Title", - }, - short_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Short Summary", - }, - long_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Long Summary", - }, - created_at: { - type: "string", - title: "Created At", - }, - share_mode: { - type: "string", - title: "Share Mode", - default: "private", - }, - source_language: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Source Language", - }, - target_language: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Target Language", - }, - reviewed: { - type: "boolean", - title: "Reviewed", - }, - meeting_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Meeting Id", - }, - source_kind: { - $ref: "#/components/schemas/SourceKind", - }, - room_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Id", - }, - room_name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Name", - }, - audio_deleted: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Audio Deleted", - }, - participants: { - anyOf: [ - { - items: { - $ref: "#/components/schemas/TranscriptParticipant", - }, - type: "array", - }, - { - type: "null", - }, - ], - title: "Participants", - }, - }, - type: "object", - required: [ - "id", - "user_id", - "name", - "status", - "locked", - "duration", - "title", - "short_summary", - "long_summary", - "created_at", - "source_language", - "target_language", - "reviewed", - "meeting_id", - "source_kind", - "participants", - ], - title: "GetTranscript", -} as const; - -export const $GetTranscriptMinimal = { - properties: { - id: { - type: "string", - title: "Id", - }, - user_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "User Id", - }, - name: { - type: "string", - title: "Name", - }, - status: { - type: "string", - title: "Status", - }, - locked: { - type: "boolean", - title: "Locked", - }, - duration: { - type: "number", - title: "Duration", - }, - title: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Title", - }, - short_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Short Summary", - }, - long_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Long Summary", - }, - created_at: { - type: "string", - title: "Created At", - }, - share_mode: { - type: "string", - title: "Share Mode", - default: "private", - }, - source_language: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Source Language", - }, - target_language: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Target Language", - }, - reviewed: { - type: "boolean", - title: "Reviewed", - }, - meeting_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Meeting Id", - }, - source_kind: { - $ref: "#/components/schemas/SourceKind", - }, - room_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Id", - }, - room_name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Name", - }, - audio_deleted: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Audio Deleted", - }, - }, - type: "object", - required: [ - "id", - "user_id", - "name", - "status", - "locked", - "duration", - "title", - "short_summary", - "long_summary", - "created_at", - "source_language", - "target_language", - "reviewed", - "meeting_id", - "source_kind", - ], - title: "GetTranscriptMinimal", -} as const; - -export const $GetTranscriptSegmentTopic = { - properties: { - text: { - type: "string", - title: "Text", - }, - start: { - type: "number", - title: "Start", - }, - speaker: { - type: "integer", - title: "Speaker", - }, - }, - type: "object", - required: ["text", "start", "speaker"], - title: "GetTranscriptSegmentTopic", -} as const; - -export const $GetTranscriptTopic = { - properties: { - id: { - type: "string", - title: "Id", - }, - title: { - type: "string", - title: "Title", - }, - summary: { - type: "string", - title: "Summary", - }, - timestamp: { - type: "number", - title: "Timestamp", - }, - duration: { - anyOf: [ - { - type: "number", - }, - { - type: "null", - }, - ], - title: "Duration", - }, - transcript: { - type: "string", - title: "Transcript", - }, - segments: { - items: { - $ref: "#/components/schemas/GetTranscriptSegmentTopic", - }, - type: "array", - title: "Segments", - default: [], - }, - }, - type: "object", - required: ["id", "title", "summary", "timestamp", "duration", "transcript"], - title: "GetTranscriptTopic", -} as const; - -export const $GetTranscriptTopicWithWords = { - properties: { - id: { - type: "string", - title: "Id", - }, - title: { - type: "string", - title: "Title", - }, - summary: { - type: "string", - title: "Summary", - }, - timestamp: { - type: "number", - title: "Timestamp", - }, - duration: { - anyOf: [ - { - type: "number", - }, - { - type: "null", - }, - ], - title: "Duration", - }, - transcript: { - type: "string", - title: "Transcript", - }, - segments: { - items: { - $ref: "#/components/schemas/GetTranscriptSegmentTopic", - }, - type: "array", - title: "Segments", - default: [], - }, - words: { - items: { - $ref: "#/components/schemas/Word", - }, - type: "array", - title: "Words", - default: [], - }, - }, - type: "object", - required: ["id", "title", "summary", "timestamp", "duration", "transcript"], - title: "GetTranscriptTopicWithWords", -} as const; - -export const $GetTranscriptTopicWithWordsPerSpeaker = { - properties: { - id: { - type: "string", - title: "Id", - }, - title: { - type: "string", - title: "Title", - }, - summary: { - type: "string", - title: "Summary", - }, - timestamp: { - type: "number", - title: "Timestamp", - }, - duration: { - anyOf: [ - { - type: "number", - }, - { - type: "null", - }, - ], - title: "Duration", - }, - transcript: { - type: "string", - title: "Transcript", - }, - segments: { - items: { - $ref: "#/components/schemas/GetTranscriptSegmentTopic", - }, - type: "array", - title: "Segments", - default: [], - }, - words_per_speaker: { - items: { - $ref: "#/components/schemas/SpeakerWords", - }, - type: "array", - title: "Words Per Speaker", - default: [], - }, - }, - type: "object", - required: ["id", "title", "summary", "timestamp", "duration", "transcript"], - title: "GetTranscriptTopicWithWordsPerSpeaker", -} as const; - -export const $HTTPValidationError = { - properties: { - detail: { - items: { - $ref: "#/components/schemas/ValidationError", - }, - type: "array", - title: "Detail", - }, - }, - type: "object", - title: "HTTPValidationError", -} as const; - -export const $Meeting = { - properties: { - id: { - type: "string", - title: "Id", - }, - room_name: { - type: "string", - title: "Room Name", - }, - room_url: { - type: "string", - title: "Room Url", - }, - host_room_url: { - type: "string", - title: "Host Room Url", - }, - start_date: { - type: "string", - format: "date-time", - title: "Start Date", - }, - end_date: { - type: "string", - format: "date-time", - title: "End Date", - }, - recording_type: { - type: "string", - enum: ["none", "local", "cloud"], - title: "Recording Type", - default: "cloud", - }, - }, - type: "object", - required: [ - "id", - "room_name", - "room_url", - "host_room_url", - "start_date", - "end_date", - ], - title: "Meeting", -} as const; - -export const $MeetingConsentRequest = { - properties: { - consent_given: { - type: "boolean", - title: "Consent Given", - }, - }, - type: "object", - required: ["consent_given"], - title: "MeetingConsentRequest", -} as const; - -export const $Page_GetTranscriptMinimal_ = { - properties: { - items: { - items: { - $ref: "#/components/schemas/GetTranscriptMinimal", - }, - type: "array", - title: "Items", - }, - total: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Total", - }, - page: { - anyOf: [ - { - type: "integer", - minimum: 1, - }, - { - type: "null", - }, - ], - title: "Page", - }, - size: { - anyOf: [ - { - type: "integer", - minimum: 1, - }, - { - type: "null", - }, - ], - title: "Size", - }, - pages: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Pages", - }, - }, - type: "object", - required: ["items", "page", "size"], - title: "Page[GetTranscriptMinimal]", -} as const; - -export const $Page_RoomDetails_ = { - properties: { - items: { - items: { - $ref: "#/components/schemas/RoomDetails", - }, - type: "array", - title: "Items", - }, - total: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Total", - }, - page: { - anyOf: [ - { - type: "integer", - minimum: 1, - }, - { - type: "null", - }, - ], - title: "Page", - }, - size: { - anyOf: [ - { - type: "integer", - minimum: 1, - }, - { - type: "null", - }, - ], - title: "Size", - }, - pages: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Pages", - }, - }, - type: "object", - required: ["items", "page", "size"], - title: "Page[RoomDetails]", -} as const; - -export const $Participant = { - properties: { - id: { - type: "string", - title: "Id", - }, - speaker: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["id", "speaker", "name"], - title: "Participant", -} as const; - -export const $Room = { - properties: { - id: { - type: "string", - title: "Id", - }, - name: { - type: "string", - title: "Name", - }, - user_id: { - type: "string", - title: "User Id", - }, - created_at: { - type: "string", - format: "date-time", - title: "Created At", - }, - zulip_auto_post: { - type: "boolean", - title: "Zulip Auto Post", - }, - zulip_stream: { - type: "string", - title: "Zulip Stream", - }, - zulip_topic: { - type: "string", - title: "Zulip Topic", - }, - is_locked: { - type: "boolean", - title: "Is Locked", - }, - room_mode: { - type: "string", - title: "Room Mode", - }, - recording_type: { - type: "string", - title: "Recording Type", - }, - recording_trigger: { - type: "string", - title: "Recording Trigger", - }, - is_shared: { - type: "boolean", - title: "Is Shared", - }, - }, - type: "object", - required: [ - "id", - "name", - "user_id", - "created_at", - "zulip_auto_post", - "zulip_stream", - "zulip_topic", - "is_locked", - "room_mode", - "recording_type", - "recording_trigger", - "is_shared", - ], - title: "Room", -} as const; - -export const $RoomDetails = { - properties: { - id: { - type: "string", - title: "Id", - }, - name: { - type: "string", - title: "Name", - }, - user_id: { - type: "string", - title: "User Id", - }, - created_at: { - type: "string", - format: "date-time", - title: "Created At", - }, - zulip_auto_post: { - type: "boolean", - title: "Zulip Auto Post", - }, - zulip_stream: { - type: "string", - title: "Zulip Stream", - }, - zulip_topic: { - type: "string", - title: "Zulip Topic", - }, - is_locked: { - type: "boolean", - title: "Is Locked", - }, - room_mode: { - type: "string", - title: "Room Mode", - }, - recording_type: { - type: "string", - title: "Recording Type", - }, - recording_trigger: { - type: "string", - title: "Recording Trigger", - }, - is_shared: { - type: "boolean", - title: "Is Shared", - }, - webhook_url: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Webhook Url", - }, - webhook_secret: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Webhook Secret", - }, - }, - type: "object", - required: [ - "id", - "name", - "user_id", - "created_at", - "zulip_auto_post", - "zulip_stream", - "zulip_topic", - "is_locked", - "room_mode", - "recording_type", - "recording_trigger", - "is_shared", - "webhook_url", - "webhook_secret", - ], - title: "RoomDetails", -} as const; - -export const $RtcOffer = { - properties: { - sdp: { - type: "string", - title: "Sdp", - }, - type: { - type: "string", - title: "Type", - }, - }, - type: "object", - required: ["sdp", "type"], - title: "RtcOffer", -} as const; - -export const $SearchResponse = { - properties: { - results: { - items: { - $ref: "#/components/schemas/SearchResult", - }, - type: "array", - title: "Results", - }, - total: { - type: "integer", - minimum: 0, - title: "Total", - description: "Total number of search results", - }, - query: { - anyOf: [ - { - type: "string", - minLength: 1, - description: "Search query text", - }, - { - type: "null", - }, - ], - title: "Query", - }, - limit: { - type: "integer", - maximum: 100, - minimum: 1, - title: "Limit", - description: "Results per page", - }, - offset: { - type: "integer", - minimum: 0, - title: "Offset", - description: "Number of results to skip", - }, - }, - type: "object", - required: ["results", "total", "limit", "offset"], - title: "SearchResponse", -} as const; - -export const $SearchResult = { - properties: { - id: { - type: "string", - minLength: 1, - title: "Id", - }, - title: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Title", - }, - user_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "User Id", - }, - room_id: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Id", - }, - room_name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Room Name", - }, - source_kind: { - $ref: "#/components/schemas/SourceKind", - }, - created_at: { - type: "string", - title: "Created At", - }, - status: { - type: "string", - minLength: 1, - title: "Status", - }, - rank: { - type: "number", - maximum: 1, - minimum: 0, - title: "Rank", - }, - duration: { - anyOf: [ - { - type: "number", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Duration", - description: "Duration in seconds", - }, - search_snippets: { - items: { - type: "string", - }, - type: "array", - title: "Search Snippets", - description: "Text snippets around search matches", - }, - total_match_count: { - type: "integer", - minimum: 0, - title: "Total Match Count", - description: "Total number of matches found in the transcript", - default: 0, - }, - }, - type: "object", - required: [ - "id", - "source_kind", - "created_at", - "status", - "rank", - "duration", - "search_snippets", - ], - title: "SearchResult", - description: "Public search result model with computed fields.", -} as const; - -export const $SourceKind = { - type: "string", - enum: ["room", "live", "file"], - title: "SourceKind", -} as const; - -export const $SpeakerAssignment = { - properties: { - speaker: { - anyOf: [ - { - type: "integer", - minimum: 0, - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - participant: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Participant", - }, - timestamp_from: { - type: "number", - title: "Timestamp From", - }, - timestamp_to: { - type: "number", - title: "Timestamp To", - }, - }, - type: "object", - required: ["timestamp_from", "timestamp_to"], - title: "SpeakerAssignment", -} as const; - -export const $SpeakerAssignmentStatus = { - properties: { - status: { - type: "string", - title: "Status", - }, - }, - type: "object", - required: ["status"], - title: "SpeakerAssignmentStatus", -} as const; - -export const $SpeakerMerge = { - properties: { - speaker_from: { - type: "integer", - title: "Speaker From", - }, - speaker_to: { - type: "integer", - title: "Speaker To", - }, - }, - type: "object", - required: ["speaker_from", "speaker_to"], - title: "SpeakerMerge", -} as const; - -export const $SpeakerWords = { - properties: { - speaker: { - type: "integer", - title: "Speaker", - }, - words: { - items: { - $ref: "#/components/schemas/Word", - }, - type: "array", - title: "Words", - }, - }, - type: "object", - required: ["speaker", "words"], - title: "SpeakerWords", -} as const; - -export const $Stream = { - properties: { - stream_id: { - type: "integer", - title: "Stream Id", - }, - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["stream_id", "name"], - title: "Stream", -} as const; - -export const $Topic = { - properties: { - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["name"], - title: "Topic", -} as const; - -export const $TranscriptParticipant = { - properties: { - id: { - type: "string", - title: "Id", - }, - speaker: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - name: { - type: "string", - title: "Name", - }, - }, - type: "object", - required: ["speaker", "name"], - title: "TranscriptParticipant", -} as const; - -export const $UpdateParticipant = { - properties: { - speaker: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Speaker", - }, - name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Name", - }, - }, - type: "object", - title: "UpdateParticipant", -} as const; - -export const $UpdateRoom = { - properties: { - name: { - type: "string", - title: "Name", - }, - zulip_auto_post: { - type: "boolean", - title: "Zulip Auto Post", - }, - zulip_stream: { - type: "string", - title: "Zulip Stream", - }, - zulip_topic: { - type: "string", - title: "Zulip Topic", - }, - is_locked: { - type: "boolean", - title: "Is Locked", - }, - room_mode: { - type: "string", - title: "Room Mode", - }, - recording_type: { - type: "string", - title: "Recording Type", - }, - recording_trigger: { - type: "string", - title: "Recording Trigger", - }, - is_shared: { - type: "boolean", - title: "Is Shared", - }, - webhook_url: { - type: "string", - title: "Webhook Url", - }, - webhook_secret: { - type: "string", - title: "Webhook Secret", - }, - }, - type: "object", - required: [ - "name", - "zulip_auto_post", - "zulip_stream", - "zulip_topic", - "is_locked", - "room_mode", - "recording_type", - "recording_trigger", - "is_shared", - "webhook_url", - "webhook_secret", - ], - title: "UpdateRoom", -} as const; - -export const $UpdateTranscript = { - properties: { - name: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Name", - }, - locked: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Locked", - }, - title: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Title", - }, - short_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Short Summary", - }, - long_summary: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Long Summary", - }, - share_mode: { - anyOf: [ - { - type: "string", - enum: ["public", "semi-private", "private"], - }, - { - type: "null", - }, - ], - title: "Share Mode", - }, - participants: { - anyOf: [ - { - items: { - $ref: "#/components/schemas/TranscriptParticipant", - }, - type: "array", - }, - { - type: "null", - }, - ], - title: "Participants", - }, - reviewed: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Reviewed", - }, - audio_deleted: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Audio Deleted", - }, - }, - type: "object", - title: "UpdateTranscript", -} as const; - -export const $UserInfo = { - properties: { - sub: { - type: "string", - title: "Sub", - }, - email: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Email", - }, - email_verified: { - anyOf: [ - { - type: "boolean", - }, - { - type: "null", - }, - ], - title: "Email Verified", - }, - }, - type: "object", - required: ["sub", "email", "email_verified"], - title: "UserInfo", -} as const; - -export const $ValidationError = { - properties: { - loc: { - items: { - anyOf: [ - { - type: "string", - }, - { - type: "integer", - }, - ], - }, - type: "array", - title: "Location", - }, - msg: { - type: "string", - title: "Message", - }, - type: { - type: "string", - title: "Error Type", - }, - }, - type: "object", - required: ["loc", "msg", "type"], - title: "ValidationError", -} as const; - -export const $WebhookTestResult = { - properties: { - success: { - type: "boolean", - title: "Success", - }, - message: { - type: "string", - title: "Message", - default: "", - }, - error: { - type: "string", - title: "Error", - default: "", - }, - status_code: { - anyOf: [ - { - type: "integer", - }, - { - type: "null", - }, - ], - title: "Status Code", - }, - response_preview: { - anyOf: [ - { - type: "string", - }, - { - type: "null", - }, - ], - title: "Response Preview", - }, - }, - type: "object", - required: ["success"], - title: "WebhookTestResult", -} as const; - -export const $WherebyWebhookEvent = { - properties: { - apiVersion: { - type: "string", - title: "Apiversion", - }, - id: { - type: "string", - title: "Id", - }, - createdAt: { - type: "string", - format: "date-time", - title: "Createdat", - }, - type: { - type: "string", - title: "Type", - }, - data: { - additionalProperties: true, - type: "object", - title: "Data", - }, - }, - type: "object", - required: ["apiVersion", "id", "createdAt", "type", "data"], - title: "WherebyWebhookEvent", -} as const; - -export const $Word = { - properties: { - text: { - type: "string", - title: "Text", - }, - start: { - type: "number", - minimum: 0, - title: "Start", - description: "Time in seconds with float part", - }, - end: { - type: "number", - minimum: 0, - title: "End", - description: "Time in seconds with float part", - }, - speaker: { - type: "integer", - title: "Speaker", - default: 0, - }, - }, - type: "object", - required: ["text", "start", "end"], - title: "Word", -} as const; diff --git a/www/app/api/services.gen.ts b/www/app/api/services.gen.ts index c9e027fb..e69de29b 100644 --- a/www/app/api/services.gen.ts +++ b/www/app/api/services.gen.ts @@ -1,942 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { CancelablePromise } from "./core/CancelablePromise"; -import type { BaseHttpRequest } from "./core/BaseHttpRequest"; -import type { - MetricsResponse, - V1MeetingAudioConsentData, - V1MeetingAudioConsentResponse, - V1RoomsListData, - V1RoomsListResponse, - V1RoomsCreateData, - V1RoomsCreateResponse, - V1RoomsGetData, - V1RoomsGetResponse, - V1RoomsUpdateData, - V1RoomsUpdateResponse, - V1RoomsDeleteData, - V1RoomsDeleteResponse, - V1RoomsCreateMeetingData, - V1RoomsCreateMeetingResponse, - V1RoomsTestWebhookData, - V1RoomsTestWebhookResponse, - V1TranscriptsListData, - V1TranscriptsListResponse, - V1TranscriptsCreateData, - V1TranscriptsCreateResponse, - V1TranscriptsSearchData, - V1TranscriptsSearchResponse, - V1TranscriptGetData, - V1TranscriptGetResponse, - V1TranscriptUpdateData, - V1TranscriptUpdateResponse, - V1TranscriptDeleteData, - V1TranscriptDeleteResponse, - V1TranscriptGetTopicsData, - V1TranscriptGetTopicsResponse, - V1TranscriptGetTopicsWithWordsData, - V1TranscriptGetTopicsWithWordsResponse, - V1TranscriptGetTopicsWithWordsPerSpeakerData, - V1TranscriptGetTopicsWithWordsPerSpeakerResponse, - V1TranscriptPostToZulipData, - V1TranscriptPostToZulipResponse, - V1TranscriptHeadAudioMp3Data, - V1TranscriptHeadAudioMp3Response, - V1TranscriptGetAudioMp3Data, - V1TranscriptGetAudioMp3Response, - V1TranscriptGetAudioWaveformData, - V1TranscriptGetAudioWaveformResponse, - V1TranscriptGetParticipantsData, - V1TranscriptGetParticipantsResponse, - V1TranscriptAddParticipantData, - V1TranscriptAddParticipantResponse, - V1TranscriptGetParticipantData, - V1TranscriptGetParticipantResponse, - V1TranscriptUpdateParticipantData, - V1TranscriptUpdateParticipantResponse, - V1TranscriptDeleteParticipantData, - V1TranscriptDeleteParticipantResponse, - V1TranscriptAssignSpeakerData, - V1TranscriptAssignSpeakerResponse, - V1TranscriptMergeSpeakerData, - V1TranscriptMergeSpeakerResponse, - V1TranscriptRecordUploadData, - V1TranscriptRecordUploadResponse, - V1TranscriptGetWebsocketEventsData, - V1TranscriptGetWebsocketEventsResponse, - V1TranscriptRecordWebrtcData, - V1TranscriptRecordWebrtcResponse, - V1TranscriptProcessData, - V1TranscriptProcessResponse, - V1UserMeResponse, - V1ZulipGetStreamsResponse, - V1ZulipGetTopicsData, - V1ZulipGetTopicsResponse, - V1WherebyWebhookData, - V1WherebyWebhookResponse, -} from "./types.gen"; - -export class DefaultService { - constructor(public readonly httpRequest: BaseHttpRequest) {} - - /** - * Metrics - * Endpoint that serves Prometheus metrics. - * @returns unknown Successful Response - * @throws ApiError - */ - public metrics(): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/metrics", - }); - } - - /** - * Meeting Audio Consent - * @param data The data for the request. - * @param data.meetingId - * @param data.requestBody - * @returns unknown Successful Response - * @throws ApiError - */ - public v1MeetingAudioConsent( - data: V1MeetingAudioConsentData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/meetings/{meeting_id}/consent", - path: { - meeting_id: data.meetingId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms List - * @param data The data for the request. - * @param data.page Page number - * @param data.size Page size - * @returns Page_RoomDetails_ Successful Response - * @throws ApiError - */ - public v1RoomsList( - data: V1RoomsListData = {}, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/rooms", - query: { - page: data.page, - size: data.size, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Create - * @param data The data for the request. - * @param data.requestBody - * @returns Room Successful Response - * @throws ApiError - */ - public v1RoomsCreate( - data: V1RoomsCreateData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/rooms", - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Get - * @param data The data for the request. - * @param data.roomId - * @returns RoomDetails Successful Response - * @throws ApiError - */ - public v1RoomsGet( - data: V1RoomsGetData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/rooms/{room_id}", - path: { - room_id: data.roomId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Update - * @param data The data for the request. - * @param data.roomId - * @param data.requestBody - * @returns RoomDetails Successful Response - * @throws ApiError - */ - public v1RoomsUpdate( - data: V1RoomsUpdateData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/rooms/{room_id}", - path: { - room_id: data.roomId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Delete - * @param data The data for the request. - * @param data.roomId - * @returns DeletionStatus Successful Response - * @throws ApiError - */ - public v1RoomsDelete( - data: V1RoomsDeleteData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "DELETE", - url: "/v1/rooms/{room_id}", - path: { - room_id: data.roomId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Create Meeting - * @param data The data for the request. - * @param data.roomName - * @returns Meeting Successful Response - * @throws ApiError - */ - public v1RoomsCreateMeeting( - data: V1RoomsCreateMeetingData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/rooms/{room_name}/meeting", - path: { - room_name: data.roomName, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Rooms Test Webhook - * Test webhook configuration by sending a sample payload. - * @param data The data for the request. - * @param data.roomId - * @returns WebhookTestResult Successful Response - * @throws ApiError - */ - public v1RoomsTestWebhook( - data: V1RoomsTestWebhookData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/rooms/{room_id}/webhook/test", - path: { - room_id: data.roomId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcripts List - * @param data The data for the request. - * @param data.sourceKind - * @param data.roomId - * @param data.searchTerm - * @param data.page Page number - * @param data.size Page size - * @returns Page_GetTranscriptMinimal_ Successful Response - * @throws ApiError - */ - public v1TranscriptsList( - data: V1TranscriptsListData = {}, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts", - query: { - source_kind: data.sourceKind, - room_id: data.roomId, - search_term: data.searchTerm, - page: data.page, - size: data.size, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcripts Create - * @param data The data for the request. - * @param data.requestBody - * @returns GetTranscript Successful Response - * @throws ApiError - */ - public v1TranscriptsCreate( - data: V1TranscriptsCreateData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts", - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcripts Search - * Full-text search across transcript titles and content. - * @param data The data for the request. - * @param data.q Search query text - * @param data.limit Results per page - * @param data.offset Number of results to skip - * @param data.roomId - * @param data.sourceKind - * @returns SearchResponse Successful Response - * @throws ApiError - */ - public v1TranscriptsSearch( - data: V1TranscriptsSearchData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/search", - query: { - q: data.q, - limit: data.limit, - offset: data.offset, - room_id: data.roomId, - source_kind: data.sourceKind, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get - * @param data The data for the request. - * @param data.transcriptId - * @returns GetTranscript Successful Response - * @throws ApiError - */ - public v1TranscriptGet( - data: V1TranscriptGetData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Update - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns GetTranscript Successful Response - * @throws ApiError - */ - public v1TranscriptUpdate( - data: V1TranscriptUpdateData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/transcripts/{transcript_id}", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Delete - * @param data The data for the request. - * @param data.transcriptId - * @returns DeletionStatus Successful Response - * @throws ApiError - */ - public v1TranscriptDelete( - data: V1TranscriptDeleteData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "DELETE", - url: "/v1/transcripts/{transcript_id}", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Topics - * @param data The data for the request. - * @param data.transcriptId - * @returns GetTranscriptTopic Successful Response - * @throws ApiError - */ - public v1TranscriptGetTopics( - data: V1TranscriptGetTopicsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/topics", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Topics With Words - * @param data The data for the request. - * @param data.transcriptId - * @returns GetTranscriptTopicWithWords Successful Response - * @throws ApiError - */ - public v1TranscriptGetTopicsWithWords( - data: V1TranscriptGetTopicsWithWordsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/topics/with-words", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Topics With Words Per Speaker - * @param data The data for the request. - * @param data.transcriptId - * @param data.topicId - * @returns GetTranscriptTopicWithWordsPerSpeaker Successful Response - * @throws ApiError - */ - public v1TranscriptGetTopicsWithWordsPerSpeaker( - data: V1TranscriptGetTopicsWithWordsPerSpeakerData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker", - path: { - transcript_id: data.transcriptId, - topic_id: data.topicId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Post To Zulip - * @param data The data for the request. - * @param data.transcriptId - * @param data.stream - * @param data.topic - * @param data.includeTopics - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptPostToZulip( - data: V1TranscriptPostToZulipData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/zulip", - path: { - transcript_id: data.transcriptId, - }, - query: { - stream: data.stream, - topic: data.topic, - include_topics: data.includeTopics, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Audio Mp3 - * @param data The data for the request. - * @param data.transcriptId - * @param data.token - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptHeadAudioMp3( - data: V1TranscriptHeadAudioMp3Data, - ): CancelablePromise { - return this.httpRequest.request({ - method: "HEAD", - url: "/v1/transcripts/{transcript_id}/audio/mp3", - path: { - transcript_id: data.transcriptId, - }, - query: { - token: data.token, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Audio Mp3 - * @param data The data for the request. - * @param data.transcriptId - * @param data.token - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptGetAudioMp3( - data: V1TranscriptGetAudioMp3Data, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/audio/mp3", - path: { - transcript_id: data.transcriptId, - }, - query: { - token: data.token, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Audio Waveform - * @param data The data for the request. - * @param data.transcriptId - * @returns AudioWaveform Successful Response - * @throws ApiError - */ - public v1TranscriptGetAudioWaveform( - data: V1TranscriptGetAudioWaveformData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/audio/waveform", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Participants - * @param data The data for the request. - * @param data.transcriptId - * @returns Participant Successful Response - * @throws ApiError - */ - public v1TranscriptGetParticipants( - data: V1TranscriptGetParticipantsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/participants", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Add Participant - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns Participant Successful Response - * @throws ApiError - */ - public v1TranscriptAddParticipant( - data: V1TranscriptAddParticipantData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/participants", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Participant - * @param data The data for the request. - * @param data.transcriptId - * @param data.participantId - * @returns Participant Successful Response - * @throws ApiError - */ - public v1TranscriptGetParticipant( - data: V1TranscriptGetParticipantData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/participants/{participant_id}", - path: { - transcript_id: data.transcriptId, - participant_id: data.participantId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Update Participant - * @param data The data for the request. - * @param data.transcriptId - * @param data.participantId - * @param data.requestBody - * @returns Participant Successful Response - * @throws ApiError - */ - public v1TranscriptUpdateParticipant( - data: V1TranscriptUpdateParticipantData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/transcripts/{transcript_id}/participants/{participant_id}", - path: { - transcript_id: data.transcriptId, - participant_id: data.participantId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Delete Participant - * @param data The data for the request. - * @param data.transcriptId - * @param data.participantId - * @returns DeletionStatus Successful Response - * @throws ApiError - */ - public v1TranscriptDeleteParticipant( - data: V1TranscriptDeleteParticipantData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "DELETE", - url: "/v1/transcripts/{transcript_id}/participants/{participant_id}", - path: { - transcript_id: data.transcriptId, - participant_id: data.participantId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Assign Speaker - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns SpeakerAssignmentStatus Successful Response - * @throws ApiError - */ - public v1TranscriptAssignSpeaker( - data: V1TranscriptAssignSpeakerData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/transcripts/{transcript_id}/speaker/assign", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Merge Speaker - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns SpeakerAssignmentStatus Successful Response - * @throws ApiError - */ - public v1TranscriptMergeSpeaker( - data: V1TranscriptMergeSpeakerData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "PATCH", - url: "/v1/transcripts/{transcript_id}/speaker/merge", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Record Upload - * @param data The data for the request. - * @param data.transcriptId - * @param data.chunkNumber - * @param data.totalChunks - * @param data.formData - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptRecordUpload( - data: V1TranscriptRecordUploadData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/record/upload", - path: { - transcript_id: data.transcriptId, - }, - query: { - chunk_number: data.chunkNumber, - total_chunks: data.totalChunks, - }, - formData: data.formData, - mediaType: "multipart/form-data", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Get Websocket Events - * @param data The data for the request. - * @param data.transcriptId - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptGetWebsocketEvents( - data: V1TranscriptGetWebsocketEventsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/transcripts/{transcript_id}/events", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Record Webrtc - * @param data The data for the request. - * @param data.transcriptId - * @param data.requestBody - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptRecordWebrtc( - data: V1TranscriptRecordWebrtcData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/record/webrtc", - path: { - transcript_id: data.transcriptId, - }, - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Transcript Process - * @param data The data for the request. - * @param data.transcriptId - * @returns unknown Successful Response - * @throws ApiError - */ - public v1TranscriptProcess( - data: V1TranscriptProcessData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/transcripts/{transcript_id}/process", - path: { - transcript_id: data.transcriptId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * User Me - * @returns unknown Successful Response - * @throws ApiError - */ - public v1UserMe(): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/me", - }); - } - - /** - * Zulip Get Streams - * Get all Zulip streams. - * @returns Stream Successful Response - * @throws ApiError - */ - public v1ZulipGetStreams(): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/zulip/streams", - }); - } - - /** - * Zulip Get Topics - * Get all topics for a specific Zulip stream. - * @param data The data for the request. - * @param data.streamId - * @returns Topic Successful Response - * @throws ApiError - */ - public v1ZulipGetTopics( - data: V1ZulipGetTopicsData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "GET", - url: "/v1/zulip/streams/{stream_id}/topics", - path: { - stream_id: data.streamId, - }, - errors: { - 422: "Validation Error", - }, - }); - } - - /** - * Whereby Webhook - * @param data The data for the request. - * @param data.requestBody - * @returns unknown Successful Response - * @throws ApiError - */ - public v1WherebyWebhook( - data: V1WherebyWebhookData, - ): CancelablePromise { - return this.httpRequest.request({ - method: "POST", - url: "/v1/whereby", - body: data.requestBody, - mediaType: "application/json", - errors: { - 422: "Validation Error", - }, - }); - } -} diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts index d724fc98..e69de29b 100644 --- a/www/app/api/types.gen.ts +++ b/www/app/api/types.gen.ts @@ -1,1143 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type AudioWaveform = { - data: Array; -}; - -export type Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post = - { - chunk: Blob | File; - }; - -export type CreateParticipant = { - speaker?: number | null; - name: string; -}; - -export type CreateRoom = { - name: string; - zulip_auto_post: boolean; - zulip_stream: string; - zulip_topic: string; - is_locked: boolean; - room_mode: string; - recording_type: string; - recording_trigger: string; - is_shared: boolean; - webhook_url: string; - webhook_secret: string; -}; - -export type CreateTranscript = { - name: string; - source_language?: string; - target_language?: string; - source_kind?: SourceKind | null; -}; - -export type DeletionStatus = { - status: string; -}; - -export type GetTranscript = { - id: string; - user_id: string | null; - name: string; - status: string; - locked: boolean; - duration: number; - title: string | null; - short_summary: string | null; - long_summary: string | null; - created_at: string; - share_mode?: string; - source_language: string | null; - target_language: string | null; - reviewed: boolean; - meeting_id: string | null; - source_kind: SourceKind; - room_id?: string | null; - room_name?: string | null; - audio_deleted?: boolean | null; - participants: Array | null; -}; - -export type GetTranscriptMinimal = { - id: string; - user_id: string | null; - name: string; - status: string; - locked: boolean; - duration: number; - title: string | null; - short_summary: string | null; - long_summary: string | null; - created_at: string; - share_mode?: string; - source_language: string | null; - target_language: string | null; - reviewed: boolean; - meeting_id: string | null; - source_kind: SourceKind; - room_id?: string | null; - room_name?: string | null; - audio_deleted?: boolean | null; -}; - -export type GetTranscriptSegmentTopic = { - text: string; - start: number; - speaker: number; -}; - -export type GetTranscriptTopic = { - id: string; - title: string; - summary: string; - timestamp: number; - duration: number | null; - transcript: string; - segments?: Array; -}; - -export type GetTranscriptTopicWithWords = { - id: string; - title: string; - summary: string; - timestamp: number; - duration: number | null; - transcript: string; - segments?: Array; - words?: Array; -}; - -export type GetTranscriptTopicWithWordsPerSpeaker = { - id: string; - title: string; - summary: string; - timestamp: number; - duration: number | null; - transcript: string; - segments?: Array; - words_per_speaker?: Array; -}; - -export type HTTPValidationError = { - detail?: Array; -}; - -export type Meeting = { - id: string; - room_name: string; - room_url: string; - host_room_url: string; - start_date: string; - end_date: string; - recording_type?: "none" | "local" | "cloud"; -}; - -export type recording_type = "none" | "local" | "cloud"; - -export type MeetingConsentRequest = { - consent_given: boolean; -}; - -export type Page_GetTranscriptMinimal_ = { - items: Array; - total?: number | null; - page: number | null; - size: number | null; - pages?: number | null; -}; - -export type Page_RoomDetails_ = { - items: Array; - total?: number | null; - page: number | null; - size: number | null; - pages?: number | null; -}; - -export type Participant = { - id: string; - speaker: number | null; - name: string; -}; - -export type Room = { - id: string; - name: string; - user_id: string; - created_at: string; - zulip_auto_post: boolean; - zulip_stream: string; - zulip_topic: string; - is_locked: boolean; - room_mode: string; - recording_type: string; - recording_trigger: string; - is_shared: boolean; -}; - -export type RoomDetails = { - id: string; - name: string; - user_id: string; - created_at: string; - zulip_auto_post: boolean; - zulip_stream: string; - zulip_topic: string; - is_locked: boolean; - room_mode: string; - recording_type: string; - recording_trigger: string; - is_shared: boolean; - webhook_url: string | null; - webhook_secret: string | null; -}; - -export type RtcOffer = { - sdp: string; - type: string; -}; - -export type SearchResponse = { - results: Array; - /** - * Total number of search results - */ - total: number; - query?: string | null; - /** - * Results per page - */ - limit: number; - /** - * Number of results to skip - */ - offset: number; -}; - -/** - * Public search result model with computed fields. - */ -export type SearchResult = { - id: string; - title?: string | null; - user_id?: string | null; - room_id?: string | null; - room_name?: string | null; - source_kind: SourceKind; - created_at: string; - status: string; - rank: number; - /** - * Duration in seconds - */ - duration: number | null; - /** - * Text snippets around search matches - */ - search_snippets: Array; - /** - * Total number of matches found in the transcript - */ - total_match_count?: number; -}; - -export type SourceKind = "room" | "live" | "file"; - -export type SpeakerAssignment = { - speaker?: number | null; - participant?: string | null; - timestamp_from: number; - timestamp_to: number; -}; - -export type SpeakerAssignmentStatus = { - status: string; -}; - -export type SpeakerMerge = { - speaker_from: number; - speaker_to: number; -}; - -export type SpeakerWords = { - speaker: number; - words: Array; -}; - -export type Stream = { - stream_id: number; - name: string; -}; - -export type Topic = { - name: string; -}; - -export type TranscriptParticipant = { - id?: string; - speaker: number | null; - name: string; -}; - -export type UpdateParticipant = { - speaker?: number | null; - name?: string | null; -}; - -export type UpdateRoom = { - name: string; - zulip_auto_post: boolean; - zulip_stream: string; - zulip_topic: string; - is_locked: boolean; - room_mode: string; - recording_type: string; - recording_trigger: string; - is_shared: boolean; - webhook_url: string; - webhook_secret: string; -}; - -export type UpdateTranscript = { - name?: string | null; - locked?: boolean | null; - title?: string | null; - short_summary?: string | null; - long_summary?: string | null; - share_mode?: "public" | "semi-private" | "private" | null; - participants?: Array | null; - reviewed?: boolean | null; - audio_deleted?: boolean | null; -}; - -export type UserInfo = { - sub: string; - email: string | null; - email_verified: boolean | null; -}; - -export type ValidationError = { - loc: Array; - msg: string; - type: string; -}; - -export type WebhookTestResult = { - success: boolean; - message?: string; - error?: string; - status_code?: number | null; - response_preview?: string | null; -}; - -export type WherebyWebhookEvent = { - apiVersion: string; - id: string; - createdAt: string; - type: string; - data: { - [key: string]: unknown; - }; -}; - -export type Word = { - text: string; - /** - * Time in seconds with float part - */ - start: number; - /** - * Time in seconds with float part - */ - end: number; - speaker?: number; -}; - -export type MetricsResponse = unknown; - -export type V1MeetingAudioConsentData = { - meetingId: string; - requestBody: MeetingConsentRequest; -}; - -export type V1MeetingAudioConsentResponse = unknown; - -export type V1RoomsListData = { - /** - * Page number - */ - page?: number; - /** - * Page size - */ - size?: number; -}; - -export type V1RoomsListResponse = Page_RoomDetails_; - -export type V1RoomsCreateData = { - requestBody: CreateRoom; -}; - -export type V1RoomsCreateResponse = Room; - -export type V1RoomsGetData = { - roomId: string; -}; - -export type V1RoomsGetResponse = RoomDetails; - -export type V1RoomsUpdateData = { - requestBody: UpdateRoom; - roomId: string; -}; - -export type V1RoomsUpdateResponse = RoomDetails; - -export type V1RoomsDeleteData = { - roomId: string; -}; - -export type V1RoomsDeleteResponse = DeletionStatus; - -export type V1RoomsCreateMeetingData = { - roomName: string; -}; - -export type V1RoomsCreateMeetingResponse = Meeting; - -export type V1RoomsTestWebhookData = { - roomId: string; -}; - -export type V1RoomsTestWebhookResponse = WebhookTestResult; - -export type V1TranscriptsListData = { - /** - * Page number - */ - page?: number; - roomId?: string | null; - searchTerm?: string | null; - /** - * Page size - */ - size?: number; - sourceKind?: SourceKind | null; -}; - -export type V1TranscriptsListResponse = Page_GetTranscriptMinimal_; - -export type V1TranscriptsCreateData = { - requestBody: CreateTranscript; -}; - -export type V1TranscriptsCreateResponse = GetTranscript; - -export type V1TranscriptsSearchData = { - /** - * Results per page - */ - limit?: number; - /** - * Number of results to skip - */ - offset?: number; - /** - * Search query text - */ - q: string; - roomId?: string | null; - sourceKind?: SourceKind | null; -}; - -export type V1TranscriptsSearchResponse = SearchResponse; - -export type V1TranscriptGetData = { - transcriptId: string; -}; - -export type V1TranscriptGetResponse = GetTranscript; - -export type V1TranscriptUpdateData = { - requestBody: UpdateTranscript; - transcriptId: string; -}; - -export type V1TranscriptUpdateResponse = GetTranscript; - -export type V1TranscriptDeleteData = { - transcriptId: string; -}; - -export type V1TranscriptDeleteResponse = DeletionStatus; - -export type V1TranscriptGetTopicsData = { - transcriptId: string; -}; - -export type V1TranscriptGetTopicsResponse = Array; - -export type V1TranscriptGetTopicsWithWordsData = { - transcriptId: string; -}; - -export type V1TranscriptGetTopicsWithWordsResponse = - Array; - -export type V1TranscriptGetTopicsWithWordsPerSpeakerData = { - topicId: string; - transcriptId: string; -}; - -export type V1TranscriptGetTopicsWithWordsPerSpeakerResponse = - GetTranscriptTopicWithWordsPerSpeaker; - -export type V1TranscriptPostToZulipData = { - includeTopics: boolean; - stream: string; - topic: string; - transcriptId: string; -}; - -export type V1TranscriptPostToZulipResponse = unknown; - -export type V1TranscriptHeadAudioMp3Data = { - token?: string | null; - transcriptId: string; -}; - -export type V1TranscriptHeadAudioMp3Response = unknown; - -export type V1TranscriptGetAudioMp3Data = { - token?: string | null; - transcriptId: string; -}; - -export type V1TranscriptGetAudioMp3Response = unknown; - -export type V1TranscriptGetAudioWaveformData = { - transcriptId: string; -}; - -export type V1TranscriptGetAudioWaveformResponse = AudioWaveform; - -export type V1TranscriptGetParticipantsData = { - transcriptId: string; -}; - -export type V1TranscriptGetParticipantsResponse = Array; - -export type V1TranscriptAddParticipantData = { - requestBody: CreateParticipant; - transcriptId: string; -}; - -export type V1TranscriptAddParticipantResponse = Participant; - -export type V1TranscriptGetParticipantData = { - participantId: string; - transcriptId: string; -}; - -export type V1TranscriptGetParticipantResponse = Participant; - -export type V1TranscriptUpdateParticipantData = { - participantId: string; - requestBody: UpdateParticipant; - transcriptId: string; -}; - -export type V1TranscriptUpdateParticipantResponse = Participant; - -export type V1TranscriptDeleteParticipantData = { - participantId: string; - transcriptId: string; -}; - -export type V1TranscriptDeleteParticipantResponse = DeletionStatus; - -export type V1TranscriptAssignSpeakerData = { - requestBody: SpeakerAssignment; - transcriptId: string; -}; - -export type V1TranscriptAssignSpeakerResponse = SpeakerAssignmentStatus; - -export type V1TranscriptMergeSpeakerData = { - requestBody: SpeakerMerge; - transcriptId: string; -}; - -export type V1TranscriptMergeSpeakerResponse = SpeakerAssignmentStatus; - -export type V1TranscriptRecordUploadData = { - chunkNumber: number; - formData: Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post; - totalChunks: number; - transcriptId: string; -}; - -export type V1TranscriptRecordUploadResponse = unknown; - -export type V1TranscriptGetWebsocketEventsData = { - transcriptId: string; -}; - -export type V1TranscriptGetWebsocketEventsResponse = unknown; - -export type V1TranscriptRecordWebrtcData = { - requestBody: RtcOffer; - transcriptId: string; -}; - -export type V1TranscriptRecordWebrtcResponse = unknown; - -export type V1TranscriptProcessData = { - transcriptId: string; -}; - -export type V1TranscriptProcessResponse = unknown; - -export type V1UserMeResponse = UserInfo | null; - -export type V1ZulipGetStreamsResponse = Array; - -export type V1ZulipGetTopicsData = { - streamId: number; -}; - -export type V1ZulipGetTopicsResponse = Array; - -export type V1WherebyWebhookData = { - requestBody: WherebyWebhookEvent; -}; - -export type V1WherebyWebhookResponse = unknown; - -export type $OpenApiTs = { - "/metrics": { - get: { - res: { - /** - * Successful Response - */ - 200: unknown; - }; - }; - }; - "/v1/meetings/{meeting_id}/consent": { - post: { - req: V1MeetingAudioConsentData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/rooms": { - get: { - req: V1RoomsListData; - res: { - /** - * Successful Response - */ - 200: Page_RoomDetails_; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - post: { - req: V1RoomsCreateData; - res: { - /** - * Successful Response - */ - 200: Room; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/rooms/{room_id}": { - get: { - req: V1RoomsGetData; - res: { - /** - * Successful Response - */ - 200: RoomDetails; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - patch: { - req: V1RoomsUpdateData; - res: { - /** - * Successful Response - */ - 200: RoomDetails; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - delete: { - req: V1RoomsDeleteData; - res: { - /** - * Successful Response - */ - 200: DeletionStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/rooms/{room_name}/meeting": { - post: { - req: V1RoomsCreateMeetingData; - res: { - /** - * Successful Response - */ - 200: Meeting; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/rooms/{room_id}/webhook/test": { - post: { - req: V1RoomsTestWebhookData; - res: { - /** - * Successful Response - */ - 200: WebhookTestResult; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts": { - get: { - req: V1TranscriptsListData; - res: { - /** - * Successful Response - */ - 200: Page_GetTranscriptMinimal_; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - post: { - req: V1TranscriptsCreateData; - res: { - /** - * Successful Response - */ - 200: GetTranscript; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/search": { - get: { - req: V1TranscriptsSearchData; - res: { - /** - * Successful Response - */ - 200: SearchResponse; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}": { - get: { - req: V1TranscriptGetData; - res: { - /** - * Successful Response - */ - 200: GetTranscript; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - patch: { - req: V1TranscriptUpdateData; - res: { - /** - * Successful Response - */ - 200: GetTranscript; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - delete: { - req: V1TranscriptDeleteData; - res: { - /** - * Successful Response - */ - 200: DeletionStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/topics": { - get: { - req: V1TranscriptGetTopicsData; - res: { - /** - * Successful Response - */ - 200: Array; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/topics/with-words": { - get: { - req: V1TranscriptGetTopicsWithWordsData; - res: { - /** - * Successful Response - */ - 200: Array; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker": { - get: { - req: V1TranscriptGetTopicsWithWordsPerSpeakerData; - res: { - /** - * Successful Response - */ - 200: GetTranscriptTopicWithWordsPerSpeaker; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/zulip": { - post: { - req: V1TranscriptPostToZulipData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/audio/mp3": { - head: { - req: V1TranscriptHeadAudioMp3Data; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - get: { - req: V1TranscriptGetAudioMp3Data; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/audio/waveform": { - get: { - req: V1TranscriptGetAudioWaveformData; - res: { - /** - * Successful Response - */ - 200: AudioWaveform; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/participants": { - get: { - req: V1TranscriptGetParticipantsData; - res: { - /** - * Successful Response - */ - 200: Array; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - post: { - req: V1TranscriptAddParticipantData; - res: { - /** - * Successful Response - */ - 200: Participant; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/participants/{participant_id}": { - get: { - req: V1TranscriptGetParticipantData; - res: { - /** - * Successful Response - */ - 200: Participant; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - patch: { - req: V1TranscriptUpdateParticipantData; - res: { - /** - * Successful Response - */ - 200: Participant; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - delete: { - req: V1TranscriptDeleteParticipantData; - res: { - /** - * Successful Response - */ - 200: DeletionStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/speaker/assign": { - patch: { - req: V1TranscriptAssignSpeakerData; - res: { - /** - * Successful Response - */ - 200: SpeakerAssignmentStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/speaker/merge": { - patch: { - req: V1TranscriptMergeSpeakerData; - res: { - /** - * Successful Response - */ - 200: SpeakerAssignmentStatus; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/record/upload": { - post: { - req: V1TranscriptRecordUploadData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/events": { - get: { - req: V1TranscriptGetWebsocketEventsData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/record/webrtc": { - post: { - req: V1TranscriptRecordWebrtcData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/transcripts/{transcript_id}/process": { - post: { - req: V1TranscriptProcessData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/me": { - get: { - res: { - /** - * Successful Response - */ - 200: UserInfo | null; - }; - }; - }; - "/v1/zulip/streams": { - get: { - res: { - /** - * Successful Response - */ - 200: Array; - }; - }; - }; - "/v1/zulip/streams/{stream_id}/topics": { - get: { - req: V1ZulipGetTopicsData; - res: { - /** - * Successful Response - */ - 200: Array; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; - "/v1/whereby": { - post: { - req: V1WherebyWebhookData; - res: { - /** - * Successful Response - */ - 200: unknown; - /** - * Validation Error - */ - 422: HTTPValidationError; - }; - }; - }; -}; diff --git a/www/app/api/urls.ts b/www/app/api/urls.ts index bd0a910c..89ce5af8 100644 --- a/www/app/api/urls.ts +++ b/www/app/api/urls.ts @@ -1,2 +1 @@ -// TODO better connection with generated schema; it's duplication export const RECORD_A_MEETING_URL = "/transcripts/new" as const; diff --git a/www/app/layout.tsx b/www/app/layout.tsx index f73b8813..62175be9 100644 --- a/www/app/layout.tsx +++ b/www/app/layout.tsx @@ -1,7 +1,6 @@ import "./styles/globals.scss"; import { Metadata, Viewport } from "next"; import { Poppins } from "next/font/google"; -import SessionProvider from "./lib/SessionProvider"; import { ErrorProvider } from "./(errors)/errorContext"; import ErrorMessage from "./(errors)/errorMessage"; import { DomainContextProvider } from "./domainContext"; @@ -74,18 +73,16 @@ export default async function RootLayout({ return ( - - - - "something went really wrong"

}> - - - {children} - -
-
-
-
+ + + "something went really wrong"

}> + + + {children} + +
+
+
); diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx new file mode 100644 index 00000000..96f49f87 --- /dev/null +++ b/www/app/lib/AuthProvider.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { createContext, useContext } from "react"; +import { useSession as useNextAuthSession } from "next-auth/react"; +import { signOut, signIn } from "next-auth/react"; +import { configureApiAuth } from "./apiClient"; +import { assertCustomSession, CustomSession } from "./types"; +import { Session } from "next-auth"; +import { SessionAutoRefresh } from "./SessionAutoRefresh"; +import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth"; + +type AuthContextType = ( + | { status: "loading" } + | { status: "refreshing" } + | { status: "unauthenticated"; error?: string } + | { + status: "authenticated"; + accessToken: string; + accessTokenExpires: number; + user: CustomSession["user"]; + } +) & { + update: () => Promise; + signIn: typeof signIn; + signOut: typeof signOut; +}; + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const { data: session, status, update } = useNextAuthSession(); + const customSession = session ? assertCustomSession(session) : null; + + const contextValue: AuthContextType = { + ...(() => { + switch (status) { + case "loading": { + const sessionIsHere = !!customSession; + switch (sessionIsHere) { + case false: { + return { status }; + } + case true: { + return { status: "refreshing" as const }; + } + default: { + const _: never = sessionIsHere; + throw new Error("unreachable"); + } + } + } + case "authenticated": { + if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) { + // token had expired but next auth still returns "authenticated" so show user unauthenticated state + return { + status: "unauthenticated" as const, + }; + } else if (customSession?.accessToken) { + return { + status, + accessToken: customSession.accessToken, + accessTokenExpires: customSession.accessTokenExpires, + user: customSession.user, + }; + } else { + console.warn( + "illegal state: authenticated but have no session/or access token. ignoring", + ); + return { status: "unauthenticated" as const }; + } + } + case "unauthenticated": { + return { status: "unauthenticated" as const }; + } + default: { + const _: never = status; + throw new Error("unreachable"); + } + } + })(), + update, + signIn, + signOut, + }; + + // not useEffect, we need it ASAP + configureApiAuth( + contextValue.status === "authenticated" ? contextValue.accessToken : null, + ); + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx index 1e230d6c..fd29367f 100644 --- a/www/app/lib/SessionAutoRefresh.tsx +++ b/www/app/lib/SessionAutoRefresh.tsx @@ -1,5 +1,5 @@ /** - * This is a custom hook that automatically refreshes the session when the access token is about to expire. + * This is a custom provider that automatically refreshes the session when the access token is about to expire. * When communicating with the reflector API, we need to ensure that the access token is always valid. * * We could have implemented that as an interceptor on the API client, but not everything is using the @@ -7,30 +7,38 @@ */ "use client"; -import { useSession } from "next-auth/react"; import { useEffect } from "react"; -import { CustomSession } from "./types"; +import { useAuth } from "./AuthProvider"; +import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth"; -export function SessionAutoRefresh({ - children, - refreshInterval = 20 /* seconds */, -}) { - const { data: session, update } = useSession(); - const customSession = session as CustomSession; - const accessTokenExpires = customSession?.accessTokenExpires; +const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE; + +export function SessionAutoRefresh({ children }) { + const auth = useAuth(); + const accessTokenExpires = + auth.status === "authenticated" ? auth.accessTokenExpires : null; useEffect(() => { + // technical value for how often the setInterval will be polling news - not too fast (no spam in case of errors) + // and not too slow (debuggable) + const INTERVAL_REFRESH_MS = 5000; const interval = setInterval(() => { - if (accessTokenExpires) { + if (accessTokenExpires !== null) { const timeLeft = accessTokenExpires - Date.now(); - if (timeLeft < refreshInterval * 1000) { - update(); + if (timeLeft < REFRESH_BEFORE) { + auth + .update() + .then(() => {}) + .catch((e) => { + // note: 401 won't be considered error here + console.error("error refreshing auth token", e); + }); } } - }, refreshInterval * 1000); + }, INTERVAL_REFRESH_MS); return () => clearInterval(interval); - }, [accessTokenExpires, refreshInterval, update]); + }, [accessTokenExpires, auth.update]); return children; } diff --git a/www/app/lib/SessionProvider.tsx b/www/app/lib/SessionProvider.tsx deleted file mode 100644 index 9c95fbc8..00000000 --- a/www/app/lib/SessionProvider.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; -import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; -import { SessionAutoRefresh } from "./SessionAutoRefresh"; - -export default function SessionProvider({ children }) { - return ( - - {children} - - ); -} diff --git a/www/app/lib/__tests__/redisTokenCache.test.ts b/www/app/lib/__tests__/redisTokenCache.test.ts new file mode 100644 index 00000000..8ca8e8a1 --- /dev/null +++ b/www/app/lib/__tests__/redisTokenCache.test.ts @@ -0,0 +1,85 @@ +import { + getTokenCache, + setTokenCache, + deleteTokenCache, + TokenCacheEntry, + KV, +} from "../redisTokenCache"; + +const mockKV: KV & { + clear: () => void; +} = (() => { + const data = new Map(); + return { + async get(key: string): Promise { + return data.get(key) || null; + }, + + async setex(key: string, seconds_: number, value: string): Promise<"OK"> { + data.set(key, value); + return "OK"; + }, + + async del(key: string): Promise { + const existed = data.has(key); + data.delete(key); + return existed ? 1 : 0; + }, + + clear() { + data.clear(); + }, + }; +})(); + +describe("Redis Token Cache", () => { + beforeEach(() => { + mockKV.clear(); + }); + + test("basic write/read - value written equals value read", async () => { + const testKey = "token:test-user-123"; + const testValue: TokenCacheEntry = { + token: { + sub: "test-user-123", + name: "Test User", + email: "test@example.com", + accessToken: "access-token-123", + accessTokenExpires: Date.now() + 3600000, // 1 hour from now + refreshToken: "refresh-token-456", + }, + timestamp: Date.now(), + }; + + await setTokenCache(mockKV, testKey, testValue); + const retrievedValue = await getTokenCache(mockKV, testKey); + + expect(retrievedValue).not.toBeNull(); + expect(retrievedValue).toEqual(testValue); + expect(retrievedValue?.token.accessToken).toBe(testValue.token.accessToken); + expect(retrievedValue?.token.sub).toBe(testValue.token.sub); + expect(retrievedValue?.timestamp).toBe(testValue.timestamp); + }); + + test("get returns null for non-existent key", async () => { + const result = await getTokenCache(mockKV, "non-existent-key"); + expect(result).toBeNull(); + }); + + test("delete removes token from cache", async () => { + const testKey = "token:delete-test"; + const testValue: TokenCacheEntry = { + token: { + accessToken: "test-token", + accessTokenExpires: Date.now() + 3600000, + }, + timestamp: Date.now(), + }; + + await setTokenCache(mockKV, testKey, testValue); + await deleteTokenCache(mockKV, testKey); + + const result = await getTokenCache(mockKV, testKey); + expect(result).toBeNull(); + }); +}); diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx new file mode 100644 index 00000000..cd97e151 --- /dev/null +++ b/www/app/lib/apiClient.tsx @@ -0,0 +1,50 @@ +"use client"; + +import createClient from "openapi-fetch"; +import type { paths } from "../reflector-api"; +import { + queryOptions, + useMutation, + useQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; +import createFetchClient from "openapi-react-query"; +import { assertExistsAndNonEmptyString } from "./utils"; +import { isBuildPhase } from "./next"; + +const API_URL = !isBuildPhase + ? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL) + : "http://localhost"; + +// Create the base openapi-fetch client with a default URL +// The actual URL will be set via middleware in AuthProvider +export const client = createClient({ + baseUrl: API_URL, +}); + +export const $api = createFetchClient(client); + +let currentAuthToken: string | null | undefined = null; + +client.use({ + onRequest({ request }) { + if (currentAuthToken) { + request.headers.set("Authorization", `Bearer ${currentAuthToken}`); + } + // XXX Only set Content-Type if not already set (FormData will set its own boundary) + // This is a work around for uploading file, we're passing a formdata + // but the content type was still application/json + if ( + !request.headers.has("Content-Type") && + !(request.body instanceof FormData) + ) { + request.headers.set("Content-Type", "application/json"); + } + return request; + }, +}); + +// the function contract: lightweight, idempotent +export const configureApiAuth = (token: string | null | undefined) => { + currentAuthToken = token; +}; diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts new file mode 100644 index 00000000..94d84c9b --- /dev/null +++ b/www/app/lib/apiHooks.ts @@ -0,0 +1,618 @@ +"use client"; + +import { $api } from "./apiClient"; +import { useError } from "../(errors)/errorContext"; +import { useQueryClient } from "@tanstack/react-query"; +import type { components } from "../reflector-api"; +import { useAuth } from "./AuthProvider"; + +/* + * XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other + * this is either a limitation or incorrect usage of Python json schema generator + * or, limitation or incorrect usage of .d type generator from json schema + * */ + +const useAuthReady = () => { + const auth = useAuth(); + + return { + isAuthenticated: auth.status === "authenticated", + isLoading: auth.status === "loading", + }; +}; + +export function useRoomsList(page: number = 1) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms", + { + params: { + query: { page }, + }, + }, + { + enabled: isAuthenticated, + }, + ); +} + +type SourceKind = components["schemas"]["SourceKind"]; + +export function useTranscriptsSearch( + q: string = "", + options: { + limit?: number; + offset?: number; + room_id?: string; + source_kind?: SourceKind; + } = {}, +) { + return $api.useQuery( + "get", + "/v1/transcripts/search", + { + params: { + query: { + q, + limit: options.limit, + offset: options.offset, + room_id: options.room_id, + source_kind: options.source_kind, + }, + }, + }, + { + enabled: true, + }, + ); +} + +export function useTranscriptDelete() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["get", "/v1/transcripts/search"], + }); + }, + onError: (error) => { + setError(error as Error, "There was an error deleting the transcript"); + }, + }); +} + +export function useTranscriptProcess() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/transcripts/{transcript_id}/process", { + onError: (error) => { + setError(error as Error, "There was an error processing the transcript"); + }, + }); +} + +export function useTranscriptGet(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { + transcript_id: transcriptId || "", + }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useRoomGet(roomId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/rooms/{room_id}", + { + params: { + path: { room_id: roomId || "" }, + }, + }, + { + enabled: !!roomId && isAuthenticated, + }, + ); +} + +export function useRoomTestWebhook() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/rooms/{room_id}/webhook/test", { + onError: (error) => { + setError(error as Error, "There was an error testing the webhook"); + }, + }); +} + +export function useRoomCreate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/v1/rooms", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the room"); + }, + }); +} + +export function useRoomUpdate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("patch", "/v1/rooms/{room_id}", { + onSuccess: async (room) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }), + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms/{room_id}", { + params: { + path: { + room_id: room.id, + }, + }, + }).queryKey, + }), + ]); + }, + onError: (error) => { + setError(error as Error, "There was an error updating the room"); + }, + }); +} + +export function useRoomDelete() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("delete", "/v1/rooms/{room_id}", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error deleting the room"); + }, + }); +} + +export function useZulipStreams() { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/zulip/streams", + {}, + { + enabled: isAuthenticated, + }, + ); +} + +export function useZulipTopics(streamId: number | null) { + const { isAuthenticated } = useAuthReady(); + const enabled = !!streamId && isAuthenticated; + return $api.useQuery( + "get", + "/v1/zulip/streams/{stream_id}/topics", + { + params: { + path: { + stream_id: enabled ? streamId : 0, + }, + }, + }, + { + enabled, + }, + ); +} + +export function useTranscriptUpdate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", { + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error updating the transcript"); + }, + }); +} + +export function useTranscriptPostToZulip() { + const { setError } = useError(); + + // @ts-ignore - Zulip endpoint not in OpenAPI spec + return $api.useMutation("post", "/v1/transcripts/{transcript_id}/zulip", { + onError: (error) => { + setError(error as Error, "There was an error posting to Zulip"); + }, + }); +} + +export function useTranscriptUploadAudio() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/record/upload", + { + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error uploading the audio file"); + }, + }, + ); +} + +export function useTranscriptWaveform(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/audio/waveform", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useTranscriptMP3(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/audio/mp3", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useTranscriptTopics(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/topics", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useTranscriptTopicsWithWords(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/topics/with-words", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useTranscriptTopicsWithWordsPerSpeaker( + transcriptId: string | null, + topicId: string | null, +) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker", + { + params: { + path: { + transcript_id: transcriptId || "", + topic_id: topicId || "", + }, + }, + }, + { + enabled: !!transcriptId && !!topicId && isAuthenticated, + }, + ); +} + +export function useTranscriptParticipants(transcriptId: string | null) { + const { isAuthenticated } = useAuthReady(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId && isAuthenticated, + }, + ); +} + +export function useTranscriptParticipantUpdate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "patch", + "/v1/transcripts/{transcript_id}/participants/{participant_id}", + { + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error updating the participant"); + }, + }, + ); +} + +export function useTranscriptParticipantCreate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/participants", + { + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the participant"); + }, + }, + ); +} + +export function useTranscriptParticipantDelete() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "delete", + "/v1/transcripts/{transcript_id}/participants/{participant_id}", + { + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error deleting the participant"); + }, + }, + ); +} + +export function useTranscriptSpeakerAssign() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "patch", + "/v1/transcripts/{transcript_id}/speaker/assign", + { + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error assigning the speaker"); + }, + }, + ); +} + +export function useTranscriptSpeakerMerge() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "patch", + "/v1/transcripts/{transcript_id}/speaker/merge", + { + onSuccess: (data, variables) => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error merging speakers"); + }, + }, + ); +} + +export function useMeetingAudioConsent() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/meetings/{meeting_id}/consent", { + onError: (error) => { + setError(error as Error, "There was an error recording consent"); + }, + }); +} + +export function useTranscriptWebRTC() { + const { setError } = useError(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/record/webrtc", + { + onError: (error) => { + setError(error as Error, "There was an error with WebRTC connection"); + }, + }, + ); +} + +export function useTranscriptCreate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/v1/transcripts", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["get", "/v1/transcripts/search"], + }); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the transcript"); + }, + }); +} + +export function useRoomsCreateMeeting() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", { + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/rooms").queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the meeting"); + }, + }); +} diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts index 9169c694..f6e60513 100644 --- a/www/app/lib/auth.ts +++ b/www/app/lib/auth.ts @@ -1,157 +1,13 @@ -// import { kv } from "@vercel/kv"; -import Redlock, { ResourceLockedError } from "redlock"; -import { AuthOptions } from "next-auth"; -import AuthentikProvider from "next-auth/providers/authentik"; -import { JWT } from "next-auth/jwt"; -import { JWTWithAccessToken, CustomSession } from "./types"; -import Redis from "ioredis"; +export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const; +// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min +export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000; -const PRETIMEOUT = 60; // seconds before token expires to refresh it -const DEFAULT_REDIS_KEY_TIMEOUT = 60 * 60 * 24 * 30; // 30 days (refresh token expires in 30 days) -const kv = new Redis(process.env.KV_URL || "", { - tls: {}, -}); -const redlock = new Redlock([kv], {}); +export const LOGIN_REQUIRED_PAGES = [ + "/transcripts/[!new]", + "/browse(.*)", + "/rooms(.*)", +]; -redlock.on("error", (error) => { - if (error instanceof ResourceLockedError) { - return; - } - - // Log all other errors. - console.error(error); -}); - -export const authOptions: AuthOptions = { - providers: [ - AuthentikProvider({ - clientId: process.env.AUTHENTIK_CLIENT_ID as string, - clientSecret: process.env.AUTHENTIK_CLIENT_SECRET as string, - issuer: process.env.AUTHENTIK_ISSUER, - authorization: { - params: { - scope: "openid email profile offline_access", - }, - }, - }), - ], - session: { - strategy: "jwt", - }, - callbacks: { - async jwt({ token, account, user }) { - const extendedToken = token as JWTWithAccessToken; - if (account && user) { - // called only on first login - // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is - const expiresAt = (account.expires_at as number) - PRETIMEOUT; - const jwtToken = { - ...extendedToken, - accessToken: account.access_token, - accessTokenExpires: expiresAt * 1000, - refreshToken: account.refresh_token, - }; - kv.set( - `token:${jwtToken.sub}`, - JSON.stringify(jwtToken), - "EX", - DEFAULT_REDIS_KEY_TIMEOUT, - ); - return jwtToken; - } - - if (Date.now() < extendedToken.accessTokenExpires) { - return token; - } - - // access token has expired, try to update it - return await redisLockedrefreshAccessToken(token); - }, - async session({ session, token }) { - const extendedToken = token as JWTWithAccessToken; - const customSession = session as CustomSession; - customSession.accessToken = extendedToken.accessToken; - customSession.accessTokenExpires = extendedToken.accessTokenExpires; - customSession.error = extendedToken.error; - customSession.user = { - id: extendedToken.sub, - name: extendedToken.name, - email: extendedToken.email, - }; - return customSession; - }, - }, -}; - -async function redisLockedrefreshAccessToken(token: JWT) { - return await redlock.using( - [token.sub as string, "jwt-refresh"], - 5000, - async () => { - const redisToken = await kv.get(`token:${token.sub}`); - const currentToken = JSON.parse( - redisToken as string, - ) as JWTWithAccessToken; - - // if there is multiple requests for the same token, it may already have been refreshed - if (Date.now() < currentToken.accessTokenExpires) { - return currentToken; - } - - // now really do the request - const newToken = await refreshAccessToken(currentToken); - await kv.set( - `token:${currentToken.sub}`, - JSON.stringify(newToken), - "EX", - DEFAULT_REDIS_KEY_TIMEOUT, - ); - return newToken; - }, - ); -} - -async function refreshAccessToken(token: JWT): Promise { - try { - const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`; - - const options = { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - client_id: process.env.AUTHENTIK_CLIENT_ID as string, - client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string, - grant_type: "refresh_token", - refresh_token: token.refreshToken as string, - }).toString(), - method: "POST", - }; - - const response = await fetch(url, options); - if (!response.ok) { - console.error( - new Date().toISOString(), - "Failed to refresh access token. Response status:", - response.status, - ); - const responseBody = await response.text(); - console.error(new Date().toISOString(), "Response body:", responseBody); - throw new Error(`Failed to refresh access token: ${response.statusText}`); - } - const refreshedTokens = await response.json(); - return { - ...token, - accessToken: refreshedTokens.access_token, - accessTokenExpires: - Date.now() + (refreshedTokens.expires_in - PRETIMEOUT) * 1000, - refreshToken: refreshedTokens.refresh_token, - }; - } catch (error) { - console.error("Error refreshing access token", error); - return { - ...token, - error: "RefreshAccessTokenError", - } as JWTWithAccessToken; - } -} +export const PROTECTED_PAGES = new RegExp( + LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"), +); diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts new file mode 100644 index 00000000..af93b274 --- /dev/null +++ b/www/app/lib/authBackend.ts @@ -0,0 +1,178 @@ +import { AuthOptions } from "next-auth"; +import AuthentikProvider from "next-auth/providers/authentik"; +import type { JWT } from "next-auth/jwt"; +import { JWTWithAccessToken, CustomSession } from "./types"; +import { assertExists, assertExistsAndNonEmptyString } from "./utils"; +import { + REFRESH_ACCESS_TOKEN_BEFORE, + REFRESH_ACCESS_TOKEN_ERROR, +} from "./auth"; +import { + getTokenCache, + setTokenCache, + deleteTokenCache, +} from "./redisTokenCache"; +import { tokenCacheRedis } from "./redisClient"; +import { isBuildPhase } from "./next"; + +// REFRESH_ACCESS_TOKEN_BEFORE because refresh is based on access token expiration (imagine we cache it 30 days) +const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; + +const refreshLocks = new Map>(); + +const CLIENT_ID = !isBuildPhase + ? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID) + : "noop"; +const CLIENT_SECRET = !isBuildPhase + ? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_SECRET) + : "noop"; + +export const authOptions: AuthOptions = { + providers: [ + AuthentikProvider({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + issuer: process.env.AUTHENTIK_ISSUER, + authorization: { + params: { + scope: "openid email profile offline_access", + }, + }, + }), + ], + session: { + strategy: "jwt", + }, + callbacks: { + async jwt({ token, account, user }) { + const KEY = `token:${token.sub}`; + + if (account && user) { + // called only on first login + // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is + const expiresAtS = assertExists(account.expires_at); + const expiresAtMs = expiresAtS * 1000; + if (!account.access_token) { + await deleteTokenCache(tokenCacheRedis, KEY); + } else { + const jwtToken: JWTWithAccessToken = { + ...token, + accessToken: account.access_token, + accessTokenExpires: expiresAtMs, + refreshToken: account.refresh_token, + }; + await setTokenCache(tokenCacheRedis, KEY, { + token: jwtToken, + timestamp: Date.now(), + }); + return jwtToken; + } + } + + const currentToken = await getTokenCache(tokenCacheRedis, KEY); + if (currentToken && Date.now() < currentToken.token.accessTokenExpires) { + return currentToken.token; + } + + // access token has expired, try to update it + return await lockedRefreshAccessToken(token); + }, + async session({ session, token }) { + const extendedToken = token as JWTWithAccessToken; + return { + ...session, + accessToken: extendedToken.accessToken, + accessTokenExpires: extendedToken.accessTokenExpires, + error: extendedToken.error, + user: { + id: assertExists(extendedToken.sub), + name: extendedToken.name, + email: extendedToken.email, + }, + } satisfies CustomSession; + }, + }, +}; + +async function lockedRefreshAccessToken( + token: JWT, +): Promise { + const lockKey = `${token.sub}-refresh`; + + const existingRefresh = refreshLocks.get(lockKey); + if (existingRefresh) { + return await existingRefresh; + } + + const refreshPromise = (async () => { + try { + const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`); + if (cached) { + if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) { + await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); + } else if (Date.now() < cached.token.accessTokenExpires) { + return cached.token; + } + } + + const currentToken = cached?.token || (token as JWTWithAccessToken); + const newToken = await refreshAccessToken(currentToken); + + await setTokenCache(tokenCacheRedis, `token:${token.sub}`, { + token: newToken, + timestamp: Date.now(), + }); + + return newToken; + } finally { + setTimeout(() => refreshLocks.delete(lockKey), 100); + } + })(); + + refreshLocks.set(lockKey, refreshPromise); + return refreshPromise; +} + +async function refreshAccessToken(token: JWT): Promise { + try { + const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`; + + const options = { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: process.env.AUTHENTIK_CLIENT_ID as string, + client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string, + grant_type: "refresh_token", + refresh_token: token.refreshToken as string, + }).toString(), + method: "POST", + }; + + const response = await fetch(url, options); + if (!response.ok) { + console.error( + new Date().toISOString(), + "Failed to refresh access token. Response status:", + response.status, + ); + const responseBody = await response.text(); + console.error(new Date().toISOString(), "Response body:", responseBody); + throw new Error(`Failed to refresh access token: ${response.statusText}`); + } + const refreshedTokens = await response.json(); + return { + ...token, + accessToken: refreshedTokens.access_token, + accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, + refreshToken: refreshedTokens.refresh_token, + }; + } catch (error) { + console.error("Error refreshing access token", error); + return { + ...token, + error: REFRESH_ACCESS_TOKEN_ERROR, + } as JWTWithAccessToken; + } +} diff --git a/www/app/lib/edgeConfig.ts b/www/app/lib/edgeConfig.ts index 2e31e146..f234a2cf 100644 --- a/www/app/lib/edgeConfig.ts +++ b/www/app/lib/edgeConfig.ts @@ -1,5 +1,5 @@ import { get } from "@vercel/edge-config"; -import { isDevelopment } from "./utils"; +import { isBuildPhase } from "./next"; type EdgeConfig = { [domainWithDash: string]: { @@ -29,12 +29,18 @@ export function edgeDomainToKey(domain: string) { // get edge config server-side (prefer DomainContext when available), domain is the hostname export async function getConfig() { - const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname; - if (process.env.NEXT_PUBLIC_ENV === "development") { - return require("../../config").localConfig; + try { + return require("../../config").localConfig; + } catch (e) { + // next build() WILL try to execute the require above even if conditionally protected + // but thank god it at least runs catch{} block properly + if (!isBuildPhase) throw new Error(e); + return require("../../config-template").localConfig; + } } + const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname; let config = await get(edgeDomainToKey(domain)); if (typeof config !== "object") { diff --git a/www/app/lib/next.ts b/www/app/lib/next.ts new file mode 100644 index 00000000..91d88bd2 --- /dev/null +++ b/www/app/lib/next.ts @@ -0,0 +1,2 @@ +// next.js tries to run all the lib code during build phase; we don't always want it when e.g. we have connections initialized we don't want to have +export const isBuildPhase = process.env.NEXT_PHASE?.includes("build"); diff --git a/www/app/lib/queryClient.tsx b/www/app/lib/queryClient.tsx new file mode 100644 index 00000000..bd5946e0 --- /dev/null +++ b/www/app/lib/queryClient.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime) + retry: 1, + refetchOnWindowFocus: false, + }, + mutations: { + retry: 0, + }, + }, +}); diff --git a/www/app/lib/redisClient.ts b/www/app/lib/redisClient.ts new file mode 100644 index 00000000..1be36538 --- /dev/null +++ b/www/app/lib/redisClient.ts @@ -0,0 +1,46 @@ +import Redis from "ioredis"; +import { isBuildPhase } from "./next"; + +export type RedisClient = Pick; + +const getRedisClient = (): RedisClient => { + const redisUrl = process.env.KV_URL; + if (!redisUrl) { + throw new Error("KV_URL environment variable is required"); + } + const redis = new Redis(redisUrl, { + maxRetriesPerRequest: 3, + lazyConnect: true, + }); + + redis.on("error", (error) => { + console.error("Redis error:", error); + }); + + // not necessary but will indicate redis config errors by failfast at startup + // happens only once; after that connection is allowed to die and the lib is assumed to be able to restore it eventually + redis.connect().catch((e) => { + console.error("Failed to connect to Redis:", e); + process.exit(1); + }); + + return redis; +}; + +// next.js buildtime usage - we want to isolate next.js "build" time concepts here +const noopClient: RedisClient = (() => { + const noopSetex: Redis["setex"] = async () => { + return "OK" as const; + }; + const noopDel: Redis["del"] = async () => { + return 0; + }; + return { + get: async () => { + return null; + }, + setex: noopSetex, + del: noopDel, + }; +})(); +export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient(); diff --git a/www/app/lib/redisTokenCache.ts b/www/app/lib/redisTokenCache.ts new file mode 100644 index 00000000..4fa4e304 --- /dev/null +++ b/www/app/lib/redisTokenCache.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; +import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth"; + +const TokenCacheEntrySchema = z.object({ + token: z.object({ + sub: z.string().optional(), + name: z.string().nullish(), + email: z.string().nullish(), + accessToken: z.string(), + accessTokenExpires: z.number(), + refreshToken: z.string().optional(), + error: z.string().optional(), + }), + timestamp: z.number(), +}); + +const TokenCacheEntryCodec = z.codec(z.string(), TokenCacheEntrySchema, { + decode: (jsonString) => { + const parsed = JSON.parse(jsonString); + return TokenCacheEntrySchema.parse(parsed); + }, + encode: (value) => JSON.stringify(value), +}); + +export type TokenCacheEntry = z.infer; + +export type KV = { + get(key: string): Promise; + setex(key: string, seconds: number, value: string): Promise<"OK">; + del(key: string): Promise; +}; + +export async function getTokenCache( + redis: KV, + key: string, +): Promise { + const data = await redis.get(key); + if (!data) return null; + + try { + return TokenCacheEntryCodec.decode(data); + } catch (error) { + console.error("Invalid token cache data:", error); + await redis.del(key); + return null; + } +} + +export async function setTokenCache( + redis: KV, + key: string, + value: TokenCacheEntry, +): Promise { + const encodedValue = TokenCacheEntryCodec.encode(value); + const ttlSeconds = Math.floor(REFRESH_ACCESS_TOKEN_BEFORE / 1000); + await redis.setex(key, ttlSeconds, encodedValue); +} + +export async function deleteTokenCache(redis: KV, key: string): Promise { + await redis.del(key); +} diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts index 851ee5be..0576e186 100644 --- a/www/app/lib/types.ts +++ b/www/app/lib/types.ts @@ -1,10 +1,11 @@ -import { Session } from "next-auth"; -import { JWT } from "next-auth/jwt"; +import type { Session } from "next-auth"; +import type { JWT } from "next-auth/jwt"; +import { parseMaybeNonEmptyString } from "./utils"; export interface JWTWithAccessToken extends JWT { accessToken: string; accessTokenExpires: number; - refreshToken: string; + refreshToken?: string; error?: string; } @@ -12,9 +13,62 @@ export interface CustomSession extends Session { accessToken: string; accessTokenExpires: number; error?: string; - user: { - id?: string; - name?: string | null; - email?: string | null; + user: Session["user"] & { + id: string; }; } + +// assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there +// but the assumption is crucial to auth working +export const assertExtendedToken = ( + t: T, +): T & { + accessTokenExpires: number; + accessToken: string; +} => { + if ( + typeof (t as { accessTokenExpires: any }).accessTokenExpires === "number" && + !isNaN((t as { accessTokenExpires: any }).accessTokenExpires) && + typeof ( + t as { + accessToken: any; + } + ).accessToken === "string" && + parseMaybeNonEmptyString((t as { accessToken: any }).accessToken) !== null + ) { + return t as T & { + accessTokenExpires: number; + accessToken: string; + }; + } + throw new Error("Token is not extended with access token"); +}; + +export const assertExtendedTokenAndUserId = ( + t: T, +): T & { + accessTokenExpires: number; + accessToken: string; + user: U & { + id: string; + }; +} => { + const extendedToken = assertExtendedToken(t); + if (typeof (extendedToken.user as any)?.id === "string") { + return t as T & { + accessTokenExpires: number; + accessToken: string; + user: U & { + id: string; + }; + }; + } + throw new Error("Token is not extended with user id"); +}; + +// best attempt to check the session is valid +export const assertCustomSession = (s: S): CustomSession => { + const r = assertExtendedTokenAndUserId(s); + // no other checks for now + return r as CustomSession; +}; diff --git a/www/app/lib/useApi.ts b/www/app/lib/useApi.ts deleted file mode 100644 index 837ef84f..00000000 --- a/www/app/lib/useApi.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useSession, signOut } from "next-auth/react"; -import { useContext, useEffect, useState } from "react"; -import { DomainContext, featureEnabled } from "../domainContext"; -import { OpenApi, DefaultService } from "../api"; -import { CustomSession } from "./types"; -import useSessionStatus from "./useSessionStatus"; -import useSessionAccessToken from "./useSessionAccessToken"; - -export default function useApi(): DefaultService | null { - const api_url = useContext(DomainContext).api_url; - const [api, setApi] = useState(null); - const { isLoading, isAuthenticated } = useSessionStatus(); - const { accessToken, error } = useSessionAccessToken(); - - if (!api_url) throw new Error("no API URL"); - - useEffect(() => { - if (error === "RefreshAccessTokenError") { - signOut(); - } - }, [error]); - - useEffect(() => { - if (isLoading || (isAuthenticated && !accessToken)) { - return; - } - - const openApi = new OpenApi({ - BASE: api_url, - TOKEN: accessToken || undefined, - }); - - setApi(openApi); - }, [isLoading, isAuthenticated, accessToken]); - - return api?.default ?? null; -} diff --git a/www/app/lib/useLoginRequiredPages.ts b/www/app/lib/useLoginRequiredPages.ts new file mode 100644 index 00000000..37ee96b1 --- /dev/null +++ b/www/app/lib/useLoginRequiredPages.ts @@ -0,0 +1,26 @@ +// for paths that are not supposed to be public +import { PROTECTED_PAGES } from "./auth"; +import { usePathname } from "next/navigation"; +import { useAuth } from "./AuthProvider"; +import { useEffect } from "react"; + +const HOME = "/" as const; + +export const useLoginRequiredPages = () => { + const pathname = usePathname(); + const isProtected = PROTECTED_PAGES.test(pathname); + const auth = useAuth(); + const isNotLoggedIn = auth.status === "unauthenticated"; + // safety + const isLastDestination = pathname === HOME; + const shouldRedirect = isNotLoggedIn && isProtected && !isLastDestination; + useEffect(() => { + if (!shouldRedirect) return; + // on the backend, the redirect goes straight to the auth provider, but we don't have it because it's hidden inside next-auth middleware + // so we just "softly" lead the user to the main page + // warning: if HOME redirects somewhere else, we won't be protected by isLastDestination + window.location.href = HOME; + }, [shouldRedirect]); + // optionally save from blink, since window.location.href takes a bit of time + return shouldRedirect ? HOME : null; +}; diff --git a/www/app/lib/useSessionAccessToken.ts b/www/app/lib/useSessionAccessToken.ts deleted file mode 100644 index fc28c076..00000000 --- a/www/app/lib/useSessionAccessToken.ts +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useSession as useNextAuthSession } from "next-auth/react"; -import { CustomSession } from "./types"; - -export default function useSessionAccessToken() { - const { data: session } = useNextAuthSession(); - const customSession = session as CustomSession; - const naAccessToken = customSession?.accessToken; - const naAccessTokenExpires = customSession?.accessTokenExpires; - const naError = customSession?.error; - const [accessToken, setAccessToken] = useState(null); - const [accessTokenExpires, setAccessTokenExpires] = useState( - null, - ); - const [error, setError] = useState(); - - useEffect(() => { - if (naAccessToken !== accessToken) { - setAccessToken(naAccessToken); - } - }, [naAccessToken]); - - useEffect(() => { - if (naAccessTokenExpires !== accessTokenExpires) { - setAccessTokenExpires(naAccessTokenExpires); - } - }, [naAccessTokenExpires]); - - useEffect(() => { - if (naError !== error) { - setError(naError); - } - }, [naError]); - - return { - accessToken, - accessTokenExpires, - error, - }; -} diff --git a/www/app/lib/useSessionStatus.ts b/www/app/lib/useSessionStatus.ts deleted file mode 100644 index 5629c025..00000000 --- a/www/app/lib/useSessionStatus.ts +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useSession as useNextAuthSession } from "next-auth/react"; -import { Session } from "next-auth"; - -export default function useSessionStatus() { - const { status: naStatus } = useNextAuthSession(); - const [status, setStatus] = useState("loading"); - - useEffect(() => { - if (naStatus !== "loading" && naStatus !== status) { - setStatus(naStatus); - } - }, [naStatus]); - - return { - status, - isLoading: status === "loading", - isAuthenticated: status === "authenticated", - }; -} diff --git a/www/app/lib/useSessionUser.ts b/www/app/lib/useSessionUser.ts deleted file mode 100644 index 2da299f5..00000000 --- a/www/app/lib/useSessionUser.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { useSession as useNextAuthSession } from "next-auth/react"; -import { Session } from "next-auth"; - -// user type with id, name, email -export interface User { - id?: string | null; - name?: string | null; - email?: string | null; -} - -export default function useSessionUser() { - const { data: session } = useNextAuthSession(); - const [user, setUser] = useState(null); - - useEffect(() => { - if (!session?.user) { - setUser(null); - return; - } - if (JSON.stringify(session.user) !== JSON.stringify(user)) { - setUser(session.user); - } - }, [session]); - - return { - id: user?.id, - name: user?.name, - email: user?.email, - }; -} diff --git a/www/app/lib/useUserName.ts b/www/app/lib/useUserName.ts new file mode 100644 index 00000000..80814281 --- /dev/null +++ b/www/app/lib/useUserName.ts @@ -0,0 +1,7 @@ +import { useAuth } from "./AuthProvider"; + +export const useUserName = (): string | null | undefined => { + const auth = useAuth(); + if (auth.status !== "authenticated") return undefined; + return auth.user?.name || null; +}; diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts index 80d0d91b..122ab234 100644 --- a/www/app/lib/utils.ts +++ b/www/app/lib/utils.ts @@ -137,9 +137,28 @@ export function extractDomain(url) { } } -export function assertExists(value: T | null | undefined, err?: string): T { +export type NonEmptyString = string & { __brand: "NonEmptyString" }; +export const parseMaybeNonEmptyString = ( + s: string, + trim = true, +): NonEmptyString | null => { + s = trim ? s.trim() : s; + return s.length > 0 ? (s as NonEmptyString) : null; +}; +export const parseNonEmptyString = (s: string, trim = true): NonEmptyString => + assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string"); + +export const assertExists = ( + value: T | null | undefined, + err?: string, +): T => { if (value === null || value === undefined) { throw new Error(`Assertion failed: ${err ?? "value is null or undefined"}`); } return value; -} +}; + +export const assertExistsAndNonEmptyString = ( + value: string | null | undefined, +): NonEmptyString => + parseNonEmptyString(assertExists(value, "Expected non-empty string")); diff --git a/www/app/providers.tsx b/www/app/providers.tsx index f0f1ea52..2e3b78eb 100644 --- a/www/app/providers.tsx +++ b/www/app/providers.tsx @@ -6,16 +6,26 @@ import system from "./styles/theme"; import { WherebyProvider } from "@whereby.com/browser-sdk/react"; import { Toaster } from "./components/ui/toaster"; import { NuqsAdapter } from "nuqs/adapters/next/app"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "./lib/queryClient"; +import { AuthProvider } from "./lib/AuthProvider"; +import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; export function Providers({ children }: { children: React.ReactNode }) { return ( - - - {children} - - - + + + + + + {children} + + + + + + ); } diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts new file mode 100644 index 00000000..8a2cadb0 --- /dev/null +++ b/www/app/reflector-api.d.ts @@ -0,0 +1,2330 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Metrics + * @description Endpoint that serves Prometheus metrics. + */ + get: operations["metrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/meetings/{meeting_id}/consent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Meeting Audio Consent */ + post: operations["v1_meeting_audio_consent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms List */ + get: operations["v1_rooms_list"]; + put?: never; + /** Rooms Create */ + post: operations["v1_rooms_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Rooms Get */ + get: operations["v1_rooms_get"]; + put?: never; + post?: never; + /** Rooms Delete */ + delete: operations["v1_rooms_delete"]; + options?: never; + head?: never; + /** Rooms Update */ + patch: operations["v1_rooms_update"]; + trace?: never; + }; + "/v1/rooms/{room_name}/meeting": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Rooms Create Meeting */ + post: operations["v1_rooms_create_meeting"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/rooms/{room_id}/webhook/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Rooms Test Webhook + * @description Test webhook configuration by sending a sample payload. + */ + post: operations["v1_rooms_test_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcripts List */ + get: operations["v1_transcripts_list"]; + put?: never; + /** Transcripts Create */ + post: operations["v1_transcripts_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Transcripts Search + * @description Full-text search across transcript titles and content. + */ + get: operations["v1_transcripts_search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get */ + get: operations["v1_transcript_get"]; + put?: never; + post?: never; + /** Transcript Delete */ + delete: operations["v1_transcript_delete"]; + options?: never; + head?: never; + /** Transcript Update */ + patch: operations["v1_transcript_update"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics */ + get: operations["v1_transcript_get_topics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics/with-words": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics With Words */ + get: operations["v1_transcript_get_topics_with_words"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Topics With Words Per Speaker */ + get: operations["v1_transcript_get_topics_with_words_per_speaker"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/zulip": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Post To Zulip */ + post: operations["v1_transcript_post_to_zulip"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/audio/mp3": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Audio Mp3 */ + get: operations["v1_transcript_get_audio_mp3"]; + put?: never; + post?: never; + delete?: never; + options?: never; + /** Transcript Get Audio Mp3 */ + head: operations["v1_transcript_head_audio_mp3"]; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/audio/waveform": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Audio Waveform */ + get: operations["v1_transcript_get_audio_waveform"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/participants": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Participants */ + get: operations["v1_transcript_get_participants"]; + put?: never; + /** Transcript Add Participant */ + post: operations["v1_transcript_add_participant"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/participants/{participant_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Participant */ + get: operations["v1_transcript_get_participant"]; + put?: never; + post?: never; + /** Transcript Delete Participant */ + delete: operations["v1_transcript_delete_participant"]; + options?: never; + head?: never; + /** Transcript Update Participant */ + patch: operations["v1_transcript_update_participant"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/speaker/assign": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Transcript Assign Speaker */ + patch: operations["v1_transcript_assign_speaker"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/speaker/merge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Transcript Merge Speaker */ + patch: operations["v1_transcript_merge_speaker"]; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/record/upload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Record Upload */ + post: operations["v1_transcript_record_upload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Transcript Get Websocket Events */ + get: operations["v1_transcript_get_websocket_events"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/record/webrtc": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Record Webrtc */ + post: operations["v1_transcript_record_webrtc"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/transcripts/{transcript_id}/process": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transcript Process */ + post: operations["v1_transcript_process"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** User Me */ + get: operations["v1_user_me"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/zulip/streams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Zulip Get Streams + * @description Get all Zulip streams. + */ + get: operations["v1_zulip_get_streams"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/zulip/streams/{stream_id}/topics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Zulip Get Topics + * @description Get all topics for a specific Zulip stream. + */ + get: operations["v1_zulip_get_topics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/whereby": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Whereby Webhook */ + post: operations["v1_whereby_webhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** AudioWaveform */ + AudioWaveform: { + /** Data */ + data: number[]; + }; + /** Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post */ + Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post: { + /** + * Chunk + * Format: binary + */ + chunk: string; + }; + /** CreateParticipant */ + CreateParticipant: { + /** Speaker */ + speaker?: number | null; + /** Name */ + name: string; + }; + /** CreateRoom */ + CreateRoom: { + /** Name */ + name: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Webhook Url */ + webhook_url: string; + /** Webhook Secret */ + webhook_secret: string; + }; + /** CreateTranscript */ + CreateTranscript: { + /** Name */ + name: string; + /** + * Source Language + * @default en + */ + source_language: string; + /** + * Target Language + * @default en + */ + target_language: string; + source_kind?: components["schemas"]["SourceKind"] | null; + }; + /** DeletionStatus */ + DeletionStatus: { + /** Status */ + status: string; + }; + /** GetTranscript */ + GetTranscript: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Participants */ + participants: components["schemas"]["TranscriptParticipant"][] | null; + }; + /** GetTranscriptMinimal */ + GetTranscriptMinimal: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + }; + /** GetTranscriptSegmentTopic */ + GetTranscriptSegmentTopic: { + /** Text */ + text: string; + /** Start */ + start: number; + /** Speaker */ + speaker: number; + }; + /** GetTranscriptTopic */ + GetTranscriptTopic: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + }; + /** GetTranscriptTopicWithWords */ + GetTranscriptTopicWithWords: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + /** + * Words + * @default [] + */ + words: components["schemas"]["Word"][]; + }; + /** GetTranscriptTopicWithWordsPerSpeaker */ + GetTranscriptTopicWithWordsPerSpeaker: { + /** Id */ + id: string; + /** Title */ + title: string; + /** Summary */ + summary: string; + /** Timestamp */ + timestamp: number; + /** Duration */ + duration: number | null; + /** Transcript */ + transcript: string; + /** + * Segments + * @default [] + */ + segments: components["schemas"]["GetTranscriptSegmentTopic"][]; + /** + * Words Per Speaker + * @default [] + */ + words_per_speaker: components["schemas"]["SpeakerWords"][]; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** Meeting */ + Meeting: { + /** Id */ + id: string; + /** Room Name */ + room_name: string; + /** Room Url */ + room_url: string; + /** Host Room Url */ + host_room_url: string; + /** + * Start Date + * Format: date-time + */ + start_date: string; + /** + * End Date + * Format: date-time + */ + end_date: string; + /** + * Recording Type + * @default cloud + * @enum {string} + */ + recording_type: "none" | "local" | "cloud"; + }; + /** MeetingConsentRequest */ + MeetingConsentRequest: { + /** Consent Given */ + consent_given: boolean; + }; + /** Page[GetTranscriptMinimal] */ + Page_GetTranscriptMinimal_: { + /** Items */ + items: components["schemas"]["GetTranscriptMinimal"][]; + /** Total */ + total?: number | null; + /** Page */ + page: number | null; + /** Size */ + size: number | null; + /** Pages */ + pages?: number | null; + }; + /** Page[RoomDetails] */ + Page_RoomDetails_: { + /** Items */ + items: components["schemas"]["RoomDetails"][]; + /** Total */ + total?: number | null; + /** Page */ + page: number | null; + /** Size */ + size: number | null; + /** Pages */ + pages?: number | null; + }; + /** Participant */ + Participant: { + /** Id */ + id: string; + /** Speaker */ + speaker: number | null; + /** Name */ + name: string; + }; + /** Room */ + Room: { + /** Id */ + id: string; + /** Name */ + name: string; + /** User Id */ + user_id: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + }; + /** RoomDetails */ + RoomDetails: { + /** Id */ + id: string; + /** Name */ + name: string; + /** User Id */ + user_id: string; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Webhook Url */ + webhook_url: string | null; + /** Webhook Secret */ + webhook_secret: string | null; + }; + /** RtcOffer */ + RtcOffer: { + /** Sdp */ + sdp: string; + /** Type */ + type: string; + }; + /** SearchResponse */ + SearchResponse: { + /** Results */ + results: components["schemas"]["SearchResult"][]; + /** + * Total + * @description Total number of search results + */ + total: number; + /** Query */ + query?: string | null; + /** + * Limit + * @description Results per page + */ + limit: number; + /** + * Offset + * @description Number of results to skip + */ + offset: number; + }; + /** + * SearchResult + * @description Public search result model with computed fields. + */ + SearchResult: { + /** Id */ + id: string; + /** Title */ + title?: string | null; + /** User Id */ + user_id?: string | null; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Created At */ + created_at: string; + /** Status */ + status: string; + /** Rank */ + rank: number; + /** + * Duration + * @description Duration in seconds + */ + duration: number | null; + /** + * Search Snippets + * @description Text snippets around search matches + */ + search_snippets: string[]; + /** + * Total Match Count + * @description Total number of matches found in the transcript + * @default 0 + */ + total_match_count: number; + }; + /** + * SourceKind + * @enum {string} + */ + SourceKind: "room" | "live" | "file"; + /** SpeakerAssignment */ + SpeakerAssignment: { + /** Speaker */ + speaker?: number | null; + /** Participant */ + participant?: string | null; + /** Timestamp From */ + timestamp_from: number; + /** Timestamp To */ + timestamp_to: number; + }; + /** SpeakerAssignmentStatus */ + SpeakerAssignmentStatus: { + /** Status */ + status: string; + }; + /** SpeakerMerge */ + SpeakerMerge: { + /** Speaker From */ + speaker_from: number; + /** Speaker To */ + speaker_to: number; + }; + /** SpeakerWords */ + SpeakerWords: { + /** Speaker */ + speaker: number; + /** Words */ + words: components["schemas"]["Word"][]; + }; + /** Stream */ + Stream: { + /** Stream Id */ + stream_id: number; + /** Name */ + name: string; + }; + /** Topic */ + Topic: { + /** Name */ + name: string; + }; + /** TranscriptParticipant */ + TranscriptParticipant: { + /** Id */ + id?: string; + /** Speaker */ + speaker: number | null; + /** Name */ + name: string; + }; + /** UpdateParticipant */ + UpdateParticipant: { + /** Speaker */ + speaker?: number | null; + /** Name */ + name?: string | null; + }; + /** UpdateRoom */ + UpdateRoom: { + /** Name */ + name: string; + /** Zulip Auto Post */ + zulip_auto_post: boolean; + /** Zulip Stream */ + zulip_stream: string; + /** Zulip Topic */ + zulip_topic: string; + /** Is Locked */ + is_locked: boolean; + /** Room Mode */ + room_mode: string; + /** Recording Type */ + recording_type: string; + /** Recording Trigger */ + recording_trigger: string; + /** Is Shared */ + is_shared: boolean; + /** Webhook Url */ + webhook_url: string; + /** Webhook Secret */ + webhook_secret: string; + }; + /** UpdateTranscript */ + UpdateTranscript: { + /** Name */ + name?: string | null; + /** Locked */ + locked?: boolean | null; + /** Title */ + title?: string | null; + /** Short Summary */ + short_summary?: string | null; + /** Long Summary */ + long_summary?: string | null; + /** Share Mode */ + share_mode?: ("public" | "semi-private" | "private") | null; + /** Participants */ + participants?: components["schemas"]["TranscriptParticipant"][] | null; + /** Reviewed */ + reviewed?: boolean | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + }; + /** UserInfo */ + UserInfo: { + /** Sub */ + sub: string; + /** Email */ + email: string | null; + /** Email Verified */ + email_verified: boolean | null; + }; + /** ValidationError */ + ValidationError: { + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + /** WebhookTestResult */ + WebhookTestResult: { + /** Success */ + success: boolean; + /** + * Message + * @default + */ + message: string; + /** + * Error + * @default + */ + error: string; + /** Status Code */ + status_code?: number | null; + /** Response Preview */ + response_preview?: string | null; + }; + /** WherebyWebhookEvent */ + WherebyWebhookEvent: { + /** Apiversion */ + apiVersion: string; + /** Id */ + id: string; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** Type */ + type: string; + /** Data */ + data: { + [key: string]: unknown; + }; + }; + /** Word */ + Word: { + /** Text */ + text: string; + /** + * Start + * @description Time in seconds with float part + */ + start: number; + /** + * End + * @description Time in seconds with float part + */ + end: number; + /** + * Speaker + * @default 0 + */ + speaker: number; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + metrics: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + v1_meeting_audio_consent: { + parameters: { + query?: never; + header?: never; + path: { + meeting_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MeetingConsentRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_list: { + parameters: { + query?: { + /** @description Page number */ + page?: number; + /** @description Page size */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Page_RoomDetails_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateRoom"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Room"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_get: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_delete: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_update: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateRoom"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RoomDetails"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_create_meeting: { + parameters: { + query?: never; + header?: never; + path: { + room_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Meeting"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_rooms_test_webhook: { + parameters: { + query?: never; + header?: never; + path: { + room_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WebhookTestResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_list: { + parameters: { + query?: { + source_kind?: components["schemas"]["SourceKind"] | null; + room_id?: string | null; + search_term?: string | null; + /** @description Page number */ + page?: number; + /** @description Page size */ + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Page_GetTranscriptMinimal_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateTranscript"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscript"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcripts_search: { + parameters: { + query: { + /** @description Search query text */ + q: string; + /** @description Results per page */ + limit?: number; + /** @description Number of results to skip */ + offset?: number; + room_id?: string | null; + source_kind?: components["schemas"]["SourceKind"] | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscript"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_delete: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_update: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateTranscript"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscript"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopic"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics_with_words: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopicWithWords"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_topics_with_words_per_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + topic_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GetTranscriptTopicWithWordsPerSpeaker"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_post_to_zulip: { + parameters: { + query: { + stream: string; + topic: string; + include_topics: boolean; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_audio_mp3: { + parameters: { + query?: { + token?: string | null; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_head_audio_mp3: { + parameters: { + query?: { + token?: string | null; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_audio_waveform: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AudioWaveform"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_participants: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_add_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateParticipant"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_delete_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeletionStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_update_participant: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + participant_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateParticipant"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Participant"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_assign_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SpeakerAssignment"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpeakerAssignmentStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_merge_speaker: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SpeakerMerge"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpeakerAssignmentStatus"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_record_upload: { + parameters: { + query: { + chunk_number: number; + total_chunks: number; + }; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_get_websocket_events: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_record_webrtc: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RtcOffer"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_transcript_process: { + parameters: { + query?: never; + header?: never; + path: { + transcript_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_user_me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserInfo"] | null; + }; + }; + }; + }; + v1_zulip_get_streams: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Stream"][]; + }; + }; + }; + }; + v1_zulip_get_topics: { + parameters: { + query?: never; + header?: never; + path: { + stream_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Topic"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + v1_whereby_webhook: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WherebyWebhookEvent"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; +} diff --git a/www/jest.config.js b/www/jest.config.js new file mode 100644 index 00000000..d2f3247b --- /dev/null +++ b/www/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/app"], + testMatch: ["**/__tests__/**/*.test.ts"], + collectCoverage: true, + collectCoverageFrom: ["app/**/*.ts", "!app/**/*.d.ts"], +}; diff --git a/www/middleware.ts b/www/middleware.ts index 39145220..2b60d715 100644 --- a/www/middleware.ts +++ b/www/middleware.ts @@ -1,16 +1,7 @@ import { withAuth } from "next-auth/middleware"; import { getConfig } from "./app/lib/edgeConfig"; import { NextResponse } from "next/server"; - -const LOGIN_REQUIRED_PAGES = [ - "/transcripts/[!new]", - "/browse(.*)", - "/rooms(.*)", -]; - -const PROTECTED_PAGES = new RegExp( - LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"), -); +import { PROTECTED_PAGES } from "./app/lib/auth"; export const config = { matcher: [ diff --git a/www/next.config.js b/www/next.config.js index e37d5402..bbc3f710 100644 --- a/www/next.config.js +++ b/www/next.config.js @@ -2,6 +2,9 @@ const nextConfig = { output: "standalone", experimental: { esmExternals: "loose" }, + env: { + IS_CI: process.env.IS_CI, + }, }; module.exports = nextConfig; diff --git a/www/openapi-ts.config.ts b/www/openapi-ts.config.ts deleted file mode 100644 index 9304b8f7..00000000 --- a/www/openapi-ts.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from "@hey-api/openapi-ts"; - -export default defineConfig({ - client: "axios", - name: "OpenApi", - input: "http://127.0.0.1:1250/openapi.json", - output: { - path: "./app/api", - format: "prettier", - }, - services: { - asClass: true, - }, -}); diff --git a/www/package.json b/www/package.json index 482a29f6..b7511147 100644 --- a/www/package.json +++ b/www/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "next lint", "format": "prettier --write .", - "openapi": "openapi-ts" + "openapi": "openapi-typescript http://127.0.0.1:1250/openapi.json -o ./app/reflector-api.d.ts", + "test": "jest" }, "dependencies": { "@chakra-ui/react": "^3.24.2", @@ -17,21 +18,24 @@ "@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/react-fontawesome": "^0.2.0", "@sentry/nextjs": "^7.77.0", + "@tanstack/react-query": "^5.85.9", + "@types/ioredis": "^5.0.0", "@vercel/edge-config": "^0.4.1", - "@vercel/kv": "^2.0.0", "@whereby.com/browser-sdk": "^3.3.4", "autoprefixer": "10.4.20", "axios": "^1.8.2", "eslint": "^9.33.0", "eslint-config-next": "^14.2.31", "fontawesome": "^5.6.3", - "ioredis": "^5.4.1", + "ioredis": "^5.7.0", "jest-worker": "^29.6.2", "lucide-react": "^0.525.0", "next": "^14.2.30", "next-auth": "^4.24.7", "next-themes": "^0.4.6", "nuqs": "^2.4.3", + "openapi-fetch": "^0.14.0", + "openapi-react-query": "^0.5.0", "postcss": "8.4.31", "prop-types": "^15.8.1", "react": "^18.2.0", @@ -41,21 +45,24 @@ "react-markdown": "^9.0.0", "react-qr-code": "^2.0.12", "react-select-search": "^4.1.7", - "redlock": "^5.0.0-beta.2", "sass": "^1.63.6", "simple-peer": "^9.11.1", "tailwindcss": "^3.3.2", "typescript": "^5.1.6", - "wavesurfer.js": "^7.4.2" + "wavesurfer.js": "^7.4.2", + "zod": "^4.1.5" }, "main": "index.js", "repository": "https://github.com/Monadical-SAS/reflector-ui.git", "author": "Andreas ", "license": "All Rights Reserved", "devDependencies": { - "@hey-api/openapi-ts": "^0.48.0", + "@types/jest": "^30.0.0", "@types/react": "18.2.20", + "jest": "^30.1.3", + "openapi-typescript": "^7.9.1", "prettier": "^3.0.0", + "ts-jest": "^29.4.1", "vercel": "^37.3.0" }, "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748" diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index 55aef9c8..14b42c55 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -24,13 +24,16 @@ importers: version: 0.2.3(@fortawesome/fontawesome-svg-core@6.7.2)(react@18.3.1) "@sentry/nextjs": specifier: ^7.77.0 - version: 7.120.4(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) + version: 7.120.4(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) + "@tanstack/react-query": + specifier: ^5.85.9 + version: 5.85.9(react@18.3.1) + "@types/ioredis": + specifier: ^5.0.0 + version: 5.0.0 "@vercel/edge-config": specifier: ^0.4.1 version: 0.4.1 - "@vercel/kv": - specifier: ^2.0.0 - version: 2.0.0 "@whereby.com/browser-sdk": specifier: ^3.3.4 version: 3.13.1(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -50,7 +53,7 @@ importers: specifier: ^5.6.3 version: 5.6.3 ioredis: - specifier: ^5.4.1 + specifier: ^5.7.0 version: 5.7.0 jest-worker: specifier: ^29.6.2 @@ -60,16 +63,22 @@ importers: version: 0.525.0(react@18.3.1) next: specifier: ^14.2.30 - version: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + version: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) next-auth: specifier: ^4.24.7 - version: 4.24.11(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.24.11(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^2.4.3 - version: 2.4.3(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) + version: 2.4.3(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1) + openapi-fetch: + specifier: ^0.14.0 + version: 0.14.0 + openapi-react-query: + specifier: ^0.5.0 + version: 0.5.0(@tanstack/react-query@5.85.9(react@18.3.1))(openapi-fetch@0.14.0) postcss: specifier: 8.4.31 version: 8.4.31 @@ -97,9 +106,6 @@ importers: react-select-search: specifier: ^4.1.7 version: 4.1.8(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - redlock: - specifier: ^5.0.0-beta.2 - version: 5.0.0-beta.2 sass: specifier: ^1.63.6 version: 1.90.0 @@ -115,16 +121,28 @@ importers: wavesurfer.js: specifier: ^7.4.2 version: 7.10.1 + zod: + specifier: ^4.1.5 + version: 4.1.5 devDependencies: - "@hey-api/openapi-ts": - specifier: ^0.48.0 - version: 0.48.3(typescript@5.9.2) + "@types/jest": + specifier: ^30.0.0 + version: 30.0.0 "@types/react": specifier: 18.2.20 version: 18.2.20 + jest: + specifier: ^30.1.3 + version: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + openapi-typescript: + specifier: ^7.9.1 + version: 7.9.1(typescript@5.9.2) prettier: specifier: ^3.0.0 version: 3.6.2 + ts-jest: + specifier: ^29.4.1 + version: 29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)))(typescript@5.9.2) vercel: specifier: ^37.3.0 version: 37.14.0 @@ -137,12 +155,12 @@ packages: } engines: { node: ">=10" } - "@apidevtools/json-schema-ref-parser@11.6.4": + "@ampproject/remapping@2.3.0": resolution: { - integrity: sha512-9K6xOqeevacvweLGik6LnZCb1fBtCOSIWQs8d096XGeqoLKC33UVMGz9+77Gw44KvbH4pKcQPWo4ZpxkXYj05w==, + integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==, } - engines: { node: ">= 16" } + engines: { node: ">=6.0.0" } "@ark-ui/react@5.18.2": resolution: @@ -160,6 +178,20 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/compat-data@7.28.0": + resolution: + { + integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==, + } + engines: { node: ">=6.9.0" } + + "@babel/core@7.28.3": + resolution: + { + integrity: sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==, + } + engines: { node: ">=6.9.0" } + "@babel/generator@7.28.0": resolution: { @@ -167,6 +199,20 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/generator@7.28.3": + resolution: + { + integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==, + } + engines: { node: ">=6.9.0" } + + "@babel/helper-compilation-targets@7.27.2": + resolution: + { + integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==, + } + engines: { node: ">=6.9.0" } + "@babel/helper-globals@7.28.0": resolution: { @@ -181,6 +227,22 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/helper-module-transforms@7.28.3": + resolution: + { + integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0 + + "@babel/helper-plugin-utils@7.27.1": + resolution: + { + integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==, + } + engines: { node: ">=6.9.0" } + "@babel/helper-string-parser@7.27.1": resolution: { @@ -195,6 +257,20 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/helper-validator-option@7.27.1": + resolution: + { + integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==, + } + engines: { node: ">=6.9.0" } + + "@babel/helpers@7.28.3": + resolution: + { + integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==, + } + engines: { node: ">=6.9.0" } + "@babel/parser@7.28.0": resolution: { @@ -203,6 +279,156 @@ packages: engines: { node: ">=6.0.0" } hasBin: true + "@babel/parser@7.28.3": + resolution: + { + integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==, + } + engines: { node: ">=6.0.0" } + hasBin: true + + "@babel/plugin-syntax-async-generators@7.8.4": + resolution: + { + integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-bigint@7.8.3": + resolution: + { + integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-class-properties@7.12.13": + resolution: + { + integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-class-static-block@7.14.5": + resolution: + { + integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-import-attributes@7.27.1": + resolution: + { + integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-import-meta@7.10.4": + resolution: + { + integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-json-strings@7.8.3": + resolution: + { + integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-jsx@7.27.1": + resolution: + { + integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-logical-assignment-operators@7.10.4": + resolution: + { + integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3": + resolution: + { + integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-numeric-separator@7.10.4": + resolution: + { + integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-object-rest-spread@7.8.3": + resolution: + { + integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-optional-catch-binding@7.8.3": + resolution: + { + integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-optional-chaining@7.8.3": + resolution: + { + integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==, + } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-private-property-in-object@7.14.5": + resolution: + { + integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-top-level-await@7.14.5": + resolution: + { + integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + + "@babel/plugin-syntax-typescript@7.27.1": + resolution: + { + integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==, + } + engines: { node: ">=6.9.0" } + peerDependencies: + "@babel/core": ^7.0.0-0 + "@babel/runtime@7.28.2": resolution: { @@ -224,6 +450,13 @@ packages: } engines: { node: ">=6.9.0" } + "@babel/traverse@7.28.3": + resolution: + { + integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==, + } + engines: { node: ">=6.9.0" } + "@babel/types@7.28.2": resolution: { @@ -231,6 +464,12 @@ packages: } engines: { node: ">=6.9.0" } + "@bcoe/v8-coverage@0.2.3": + resolution: + { + integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, + } + "@chakra-ui/react@3.24.2": resolution: { @@ -516,16 +755,6 @@ packages: "@fortawesome/fontawesome-svg-core": ~1 || ~6 || ~7 react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0 - "@hey-api/openapi-ts@0.48.3": - resolution: - { - integrity: sha512-R53Nr4Gicz77icS+RiH0fwHa9A0uFPtzsjC8SBaGwtOel5ZyxeBbayWE6HhE789hp3dok9pegwWncwwOrr4WFA==, - } - engines: { node: ^18.0.0 || >=20.0.0 } - hasBin: true - peerDependencies: - typescript: ^5.x - "@humanfs/core@0.19.1": resolution: { @@ -573,10 +802,10 @@ packages: integrity: sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==, } - "@ioredis/commands@1.3.0": + "@ioredis/commands@1.3.1": resolution: { - integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==, + integrity: sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==, } "@isaacs/cliui@8.0.2": @@ -586,6 +815,107 @@ packages: } engines: { node: ">=12" } + "@istanbuljs/load-nyc-config@1.1.0": + resolution: + { + integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==, + } + engines: { node: ">=8" } + + "@istanbuljs/schema@0.1.3": + resolution: + { + integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==, + } + engines: { node: ">=8" } + + "@jest/console@30.1.2": + resolution: + { + integrity: sha512-BGMAxj8VRmoD0MoA/jo9alMXSRoqW8KPeqOfEo1ncxnRLatTBCpRoOwlwlEMdudp68Q6WSGwYrrLtTGOh8fLzw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/core@30.1.3": + resolution: + { + integrity: sha512-LIQz7NEDDO1+eyOA2ZmkiAyYvZuo6s1UxD/e2IHldR6D7UYogVq3arTmli07MkENLq6/3JEQjp0mA8rrHHJ8KQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + "@jest/diff-sequences@30.0.1": + resolution: + { + integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/environment@30.1.2": + resolution: + { + integrity: sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/expect-utils@30.1.2": + resolution: + { + integrity: sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/expect@30.1.2": + resolution: + { + integrity: sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/fake-timers@30.1.2": + resolution: + { + integrity: sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/get-type@30.1.0": + resolution: + { + integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/globals@30.1.2": + resolution: + { + integrity: sha512-teNTPZ8yZe3ahbYnvnVRDeOjr+3pu2uiAtNtrEsiMjVPPj+cXd5E/fr8BL7v/T7F31vYdEHrI5cC/2OoO/vM9A==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/pattern@30.0.1": + resolution: + { + integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/reporters@30.1.3": + resolution: + { + integrity: sha512-VWEQmJWfXMOrzdFEOyGjUEOuVXllgZsoPtEHZzfdNz18RmzJ5nlR6kp8hDdY8dDS1yGOXAY7DHT+AOHIPSBV0w==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + "@jest/schemas@29.6.3": resolution: { @@ -593,6 +923,48 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + "@jest/schemas@30.0.5": + resolution: + { + integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/snapshot-utils@30.1.2": + resolution: + { + integrity: sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/source-map@30.0.1": + resolution: + { + integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/test-result@30.1.3": + resolution: + { + integrity: sha512-P9IV8T24D43cNRANPPokn7tZh0FAFnYS2HIfi5vK18CjRkTDR9Y3e1BoEcAJnl4ghZZF4Ecda4M/k41QkvurEQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/test-sequencer@30.1.3": + resolution: + { + integrity: sha512-82J+hzC0qeQIiiZDThh+YUadvshdBswi5nuyXlEmXzrhw5ZQSRHeQ5LpVMD/xc8B3wPePvs6VMzHnntxL+4E3w==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + "@jest/transform@30.1.2": + resolution: + { + integrity: sha512-UYYFGifSgfjujf1Cbd3iU/IQoSd6uwsj8XHj5DSDf5ERDcWMdJOPTkHWXj4U+Z/uMagyOQZ6Vne8C4nRIrCxqA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + "@jest/types@29.6.3": resolution: { @@ -600,6 +972,13 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + "@jest/types@30.0.5": + resolution: + { + integrity: sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + "@jridgewell/gen-mapping@0.3.13": resolution: { @@ -631,12 +1010,6 @@ packages: integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, } - "@jsdevtools/ono@7.1.3": - resolution: - { - integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==, - } - "@mapbox/node-pre-gyp@1.0.11": resolution: { @@ -914,6 +1287,13 @@ packages: } engines: { node: ">=14" } + "@pkgr/core@0.2.9": + resolution: + { + integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==, + } + engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 } + "@radix-ui/primitive@1.1.3": resolution: { @@ -1198,6 +1578,25 @@ packages: integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==, } + "@redocly/ajv@8.11.3": + resolution: + { + integrity: sha512-4P3iZse91TkBiY+Dx5DUgxQ9GXkVJf++cmI0MOyLDxV9b5MUBI4II6ES8zA5JCbO72nKAJxWrw4PUPW+YP3ZDQ==, + } + + "@redocly/config@0.22.2": + resolution: + { + integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==, + } + + "@redocly/openapi-core@1.34.5": + resolution: + { + integrity: sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==, + } + engines: { node: ">=18.17.0", npm: ">=9.5.0" } + "@reduxjs/toolkit@2.8.2": resolution: { @@ -1382,6 +1781,24 @@ packages: integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==, } + "@sinclair/typebox@0.34.41": + resolution: + { + integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==, + } + + "@sinonjs/commons@3.0.1": + resolution: + { + integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==, + } + + "@sinonjs/fake-timers@13.0.5": + resolution: + { + integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==, + } + "@socket.io/component-emitter@3.1.2": resolution: { @@ -1418,6 +1835,20 @@ packages: integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==, } + "@tanstack/query-core@5.85.9": + resolution: + { + integrity: sha512-5fxb9vwyftYE6KFLhhhDyLr8NO75+Wpu7pmTo+TkwKmMX2oxZDoLwcqGP8ItKSpUMwk3urWgQDZfyWr5Jm9LsQ==, + } + + "@tanstack/react-query@5.85.9": + resolution: + { + integrity: sha512-2T5zgSpcOZXGkH/UObIbIkGmUPQqZqn7esVQFXLOze622h4spgWf5jmvrqAo9dnI13/hyMcNsF1jsoDcb59nJQ==, + } + peerDependencies: + react: ^18 || ^19 + "@tootallnate/once@2.0.0": resolution: { @@ -1461,6 +1892,30 @@ packages: integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==, } + "@types/babel__core@7.20.5": + resolution: + { + integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, + } + + "@types/babel__generator@7.27.0": + resolution: + { + integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==, + } + + "@types/babel__template@7.4.4": + resolution: + { + integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==, + } + + "@types/babel__traverse@7.28.0": + resolution: + { + integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==, + } + "@types/debug@4.1.12": resolution: { @@ -1491,6 +1946,13 @@ packages: integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==, } + "@types/ioredis@5.0.0": + resolution: + { + integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==, + } + deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed. + "@types/istanbul-lib-coverage@2.0.6": resolution: { @@ -1509,6 +1971,12 @@ packages: integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==, } + "@types/jest@30.0.0": + resolution: + { + integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==, + } + "@types/json-schema@7.0.15": resolution: { @@ -1575,6 +2043,12 @@ packages: integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==, } + "@types/stack-utils@2.0.3": + resolution: + { + integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==, + } + "@types/ua-parser-js@0.7.39": resolution: { @@ -1888,12 +2362,6 @@ packages: cpu: [x64] os: [win32] - "@upstash/redis@1.35.3": - resolution: - { - integrity: sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w==, - } - "@vercel/build-utils@8.4.12": resolution: { @@ -1950,13 +2418,6 @@ packages: integrity: sha512-IPAVaALuGAzt2apvTtBs5tB+8zZRzn/yG3AGp8dFyCsw/v5YOuk0Q5s8Z3fayLvJbFpjrKtqRNDZzVJBBU3MrQ==, } - "@vercel/kv@2.0.0": - resolution: - { - integrity: sha512-zdVrhbzZBYo5d1Hfn4bKtqCeKf0FuzW8rSHauzQVMUgv1+1JOwof2mWcBuI+YMJy8s0G0oqAUfQ7HgUDzb8EbA==, - } - engines: { node: ">=14.6" } - "@vercel/next@4.3.18": resolution: { @@ -2502,6 +2963,13 @@ packages: } engines: { node: ">= 6.0.0" } + agent-base@7.1.4: + resolution: + { + integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==, + } + engines: { node: ">= 14" } + ajv@6.12.6: resolution: { @@ -2514,6 +2982,20 @@ packages: integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==, } + ansi-colors@4.1.3: + resolution: + { + integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==, + } + engines: { node: ">=6" } + + ansi-escapes@4.3.2: + resolution: + { + integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==, + } + engines: { node: ">=8" } + ansi-regex@5.0.1: resolution: { @@ -2535,6 +3017,13 @@ packages: } engines: { node: ">=8" } + ansi-styles@5.2.0: + resolution: + { + integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, + } + engines: { node: ">=10" } + ansi-styles@6.2.1: resolution: { @@ -2587,6 +3076,12 @@ packages: integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==, } + argparse@1.0.10: + resolution: + { + integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==, + } + argparse@2.0.1: resolution: { @@ -2758,6 +3253,29 @@ packages: } engines: { node: ">= 0.4" } + babel-jest@30.1.2: + resolution: + { + integrity: sha512-IQCus1rt9kaSh7PQxLYRY5NmkNrNlU2TpabzwV7T2jljnpdHOcmnYYv8QmE04Li4S3a2Lj8/yXyET5pBarPr6g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + "@babel/core": ^7.11.0 + + babel-plugin-istanbul@7.0.0: + resolution: + { + integrity: sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==, + } + engines: { node: ">=12" } + + babel-plugin-jest-hoist@30.0.1: + resolution: + { + integrity: sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + babel-plugin-macros@3.1.0: resolution: { @@ -2765,6 +3283,23 @@ packages: } engines: { node: ">=10", npm: ">=6" } + babel-preset-current-node-syntax@1.2.0: + resolution: + { + integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==, + } + peerDependencies: + "@babel/core": ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.0.1: + resolution: + { + integrity: sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + "@babel/core": ^7.11.0 + bail@2.0.2: resolution: { @@ -2823,6 +3358,19 @@ packages: engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } hasBin: true + bs-logger@0.2.6: + resolution: + { + integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==, + } + engines: { node: ">= 6" } + + bser@2.1.1: + resolution: + { + integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==, + } + btoa@1.2.1: resolution: { @@ -2837,6 +3385,12 @@ packages: integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==, } + buffer-from@1.1.2: + resolution: + { + integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, + } + buffer@6.0.3: resolution: { @@ -2857,17 +3411,6 @@ packages: } engines: { node: ">= 0.8" } - c12@1.11.1: - resolution: - { - integrity: sha512-KDU0TvSvVdaYcQKQ6iPHATGz/7p/KiVjPg4vQrB6Jg/wX9R0yl5RZxWm9IoZqaIHD2+6PZd81+KMGwRr/lRIUg==, - } - peerDependencies: - magicast: ^0.3.4 - peerDependenciesMeta: - magicast: - optional: true - call-bind-apply-helpers@1.0.2: resolution: { @@ -2903,12 +3446,19 @@ packages: } engines: { node: ">= 6" } - camelcase@8.0.0: + camelcase@5.3.1: resolution: { - integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==, + integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==, } - engines: { node: ">=16" } + engines: { node: ">=6" } + + camelcase@6.3.0: + resolution: + { + integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==, + } + engines: { node: ">=10" } caniuse-lite@1.0.30001734: resolution: @@ -2936,6 +3486,19 @@ packages: } engines: { node: ">=10" } + change-case@5.4.4: + resolution: + { + integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==, + } + + char-regex@1.0.2: + resolution: + { + integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==, + } + engines: { node: ">=10" } + character-entities-html4@2.1.0: resolution: { @@ -3007,11 +3570,12 @@ packages: } engines: { node: ">=8" } - citty@0.1.6: + ci-info@4.3.0: resolution: { - integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==, + integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==, } + engines: { node: ">=8" } cjs-module-lexer@1.2.3: resolution: @@ -3019,6 +3583,12 @@ packages: integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==, } + cjs-module-lexer@2.1.0: + resolution: + { + integrity: sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==, + } + classnames@2.5.1: resolution: { @@ -3031,6 +3601,13 @@ packages: integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==, } + cliui@8.0.1: + resolution: + { + integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==, + } + engines: { node: ">=12" } + clsx@2.1.1: resolution: { @@ -3045,12 +3622,25 @@ packages: } engines: { node: ">=0.10.0" } + co@4.6.0: + resolution: + { + integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==, + } + engines: { iojs: ">= 1.0.0", node: ">= 0.12.0" } + code-block-writer@10.1.1: resolution: { integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==, } + collect-v8-coverage@1.0.2: + resolution: + { + integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==, + } + color-convert@2.0.1: resolution: { @@ -3071,6 +3661,12 @@ packages: } hasBin: true + colorette@1.4.0: + resolution: + { + integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==, + } + combined-stream@1.0.8: resolution: { @@ -3084,13 +3680,6 @@ packages: integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==, } - commander@12.1.0: - resolution: - { - integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==, - } - engines: { node: ">=18" } - commander@4.1.1: resolution: { @@ -3110,19 +3699,6 @@ packages: integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, } - confbox@0.1.8: - resolution: - { - integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==, - } - - consola@3.4.2: - resolution: - { - integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==, - } - engines: { node: ^14.18.0 || >=16.10.0 } - console-control-strings@1.1.0: resolution: { @@ -3149,6 +3725,12 @@ packages: integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==, } + convert-source-map@2.0.0: + resolution: + { + integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, + } + cookie@0.7.2: resolution: { @@ -3270,12 +3852,30 @@ packages: integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==, } + dedent@1.7.0: + resolution: + { + integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==, + } + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-is@0.1.4: resolution: { integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, } + deepmerge@4.3.1: + resolution: + { + integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==, + } + engines: { node: ">=0.10.0" } + define-data-property@1.1.4: resolution: { @@ -3290,12 +3890,6 @@ packages: } engines: { node: ">= 0.4" } - defu@6.1.4: - resolution: - { - integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==, - } - delayed-stream@1.0.0: resolution: { @@ -3330,12 +3924,6 @@ packages: } engines: { node: ">=6" } - destr@2.0.5: - resolution: - { - integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==, - } - detect-europe-js@0.1.2: resolution: { @@ -3357,6 +3945,13 @@ packages: } engines: { node: ">=8" } + detect-newline@3.1.0: + resolution: + { + integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==, + } + engines: { node: ">=8" } + detect-node-es@1.1.0: resolution: { @@ -3413,13 +4008,6 @@ packages: integrity: sha512-h7g5eduvnLwowJJPkcB5lNzo8vd/Hx4e3I4IOtLpX0qB2wBiuryGLNa61MeFre4b6gMaQIhegMIZ2I8rQCAJwQ==, } - dotenv@16.6.1: - resolution: - { - integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==, - } - engines: { node: ">=12" } - dunder-proto@1.0.1: resolution: { @@ -3447,6 +4035,13 @@ packages: integrity: sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==, } + emittery@0.13.1: + resolution: + { + integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==, + } + engines: { node: ">=12" } + emoji-regex@8.0.0: resolution: { @@ -3753,6 +4348,13 @@ packages: } engines: { node: ">=6" } + escape-string-regexp@2.0.0: + resolution: + { + integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==, + } + engines: { node: ">=8" } + escape-string-regexp@4.0.0: resolution: { @@ -3899,6 +4501,14 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + esprima@4.0.1: + resolution: + { + integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==, + } + engines: { node: ">=4" } + hasBin: true + esquery@1.6.0: resolution: { @@ -3973,6 +4583,27 @@ packages: } engines: { node: ^8.12.0 || >=9.7.0 } + execa@5.1.1: + resolution: + { + integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==, + } + engines: { node: ">=10" } + + exit-x@0.2.2: + resolution: + { + integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==, + } + engines: { node: ">= 0.8.0" } + + expect@30.1.2: + resolution: + { + integrity: sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + extend@3.0.2: resolution: { @@ -4022,6 +4653,12 @@ packages: integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==, } + fb-watchman@2.0.2: + resolution: + { + integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==, + } + fd-slicer@1.1.0: resolution: { @@ -4065,6 +4702,13 @@ packages: integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==, } + find-up@4.1.0: + resolution: + { + integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, + } + engines: { node: ">=8" } + find-up@5.0.0: resolution: { @@ -4213,12 +4857,26 @@ packages: } engines: { node: ">= 4" } + gensync@1.0.0-beta.2: + resolution: + { + integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, + } + engines: { node: ">=6.9.0" } + get-browser-rtc@1.1.0: resolution: { integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==, } + get-caller-file@2.0.5: + resolution: + { + integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, + } + engines: { node: 6.* || 8.* || >= 10.* } + get-intrinsic@1.3.0: resolution: { @@ -4233,6 +4891,13 @@ packages: } engines: { node: ">=6" } + get-package-type@0.1.0: + resolution: + { + integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==, + } + engines: { node: ">=8.0.0" } + get-proto@1.0.1: resolution: { @@ -4247,6 +4912,13 @@ packages: } engines: { node: ">=8" } + get-stream@6.0.1: + resolution: + { + integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==, + } + engines: { node: ">=10" } + get-symbol-description@1.1.0: resolution: { @@ -4260,13 +4932,6 @@ packages: integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==, } - giget@1.2.5: - resolution: - { - integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==, - } - hasBin: true - glob-parent@5.1.2: resolution: { @@ -4437,6 +5102,12 @@ packages: integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==, } + html-escaper@2.0.2: + resolution: + { + integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, + } + html-url-attributes@3.0.1: resolution: { @@ -4464,6 +5135,13 @@ packages: } engines: { node: ">= 6" } + https-proxy-agent@7.0.6: + resolution: + { + integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, + } + engines: { node: ">= 14" } + human-signals@1.1.1: resolution: { @@ -4471,6 +5149,13 @@ packages: } engines: { node: ">=8.12.0" } + human-signals@2.1.0: + resolution: + { + integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, + } + engines: { node: ">=10.17.0" } + hyperhtml-style@0.1.3: resolution: { @@ -4529,6 +5214,14 @@ packages: } engines: { node: ">=6" } + import-local@3.2.0: + resolution: + { + integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==, + } + engines: { node: ">=8" } + hasBin: true + imurmurhash@0.1.4: resolution: { @@ -4536,6 +5229,13 @@ packages: } engines: { node: ">=0.8.19" } + index-to-position@1.1.0: + resolution: + { + integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==, + } + engines: { node: ">=18" } + inflight@1.0.6: resolution: { @@ -4709,6 +5409,13 @@ packages: } engines: { node: ">=8" } + is-generator-fn@2.1.0: + resolution: + { + integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==, + } + engines: { node: ">=6" } + is-generator-function@1.1.0: resolution: { @@ -4864,6 +5571,41 @@ packages: integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, } + istanbul-lib-coverage@3.2.2: + resolution: + { + integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==, + } + engines: { node: ">=8" } + + istanbul-lib-instrument@6.0.3: + resolution: + { + integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==, + } + engines: { node: ">=10" } + + istanbul-lib-report@3.0.1: + resolution: + { + integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==, + } + engines: { node: ">=10" } + + istanbul-lib-source-maps@5.0.6: + resolution: + { + integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==, + } + engines: { node: ">=10" } + + istanbul-reports@3.2.0: + resolution: + { + integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==, + } + engines: { node: ">=8" } + iterator.prototype@1.1.5: resolution: { @@ -4884,6 +5626,168 @@ packages: integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, } + jest-changed-files@30.0.5: + resolution: + { + integrity: sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-circus@30.1.3: + resolution: + { + integrity: sha512-Yf3dnhRON2GJT4RYzM89t/EXIWNxKTpWTL9BfF3+geFetWP4XSvJjiU1vrWplOiUkmq8cHLiwuhz+XuUp9DscA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-cli@30.1.3: + resolution: + { + integrity: sha512-G8E2Ol3OKch1DEeIBl41NP7OiC6LBhfg25Btv+idcusmoUSpqUkbrneMqbW9lVpI/rCKb/uETidb7DNteheuAQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.1.3: + resolution: + { + integrity: sha512-M/f7gqdQEPgZNA181Myz+GXCe8jXcJsGjCMXUzRj22FIXsZOyHNte84e0exntOvdPaeh9tA0w+B8qlP2fAezfw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + "@types/node": "*" + esbuild-register: ">=3.4.0" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.1.2: + resolution: + { + integrity: sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-docblock@30.0.1: + resolution: + { + integrity: sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-each@30.1.0: + resolution: + { + integrity: sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-environment-node@30.1.2: + resolution: + { + integrity: sha512-w8qBiXtqGWJ9xpJIA98M0EIoq079GOQRQUyse5qg1plShUCQ0Ek1VTTcczqKrn3f24TFAgFtT+4q3aOXvjbsuA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-haste-map@30.1.0: + resolution: + { + integrity: sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-leak-detector@30.1.0: + resolution: + { + integrity: sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-matcher-utils@30.1.2: + resolution: + { + integrity: sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-message-util@30.1.0: + resolution: + { + integrity: sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-mock@30.0.5: + resolution: + { + integrity: sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-pnp-resolver@1.2.3: + resolution: + { + integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==, + } + engines: { node: ">=6" } + peerDependencies: + jest-resolve: "*" + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: + { + integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-resolve-dependencies@30.1.3: + resolution: + { + integrity: sha512-DNfq3WGmuRyHRHfEet+Zm3QOmVFtIarUOQHHryKPc0YL9ROfgWZxl4+aZq/VAzok2SS3gZdniP+dO4zgo59hBg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-resolve@30.1.3: + resolution: + { + integrity: sha512-DI4PtTqzw9GwELFS41sdMK32Ajp3XZQ8iygeDMWkxlRhm7uUTOFSZFVZABFuxr0jvspn8MAYy54NxZCsuCTSOw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-runner@30.1.3: + resolution: + { + integrity: sha512-dd1ORcxQraW44Uz029TtXj85W11yvLpDuIzNOlofrC8GN+SgDlgY4BvyxJiVeuabA1t6idjNbX59jLd2oplOGQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-runtime@30.1.3: + resolution: + { + integrity: sha512-WS8xgjuNSphdIGnleQcJ3AKE4tBKOVP+tKhCD0u+Tb2sBmsU8DxfbBpZX7//+XOz81zVs4eFpJQwBNji2Y07DA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-snapshot@30.1.2: + resolution: + { + integrity: sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest-util@29.7.0: resolution: { @@ -4891,6 +5795,27 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + jest-util@30.0.5: + resolution: + { + integrity: sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-validate@30.1.0: + resolution: + { + integrity: sha512-7P3ZlCFW/vhfQ8pE7zW6Oi4EzvuB4sgR72Q1INfW9m0FGo0GADYlPwIkf4CyPq7wq85g+kPMtPOHNAdWHeBOaA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-watcher@30.1.3: + resolution: + { + integrity: sha512-6jQUZCP1BTL2gvG9E4YF06Ytq4yMb4If6YoQGRR6PpjtqOXSP3sKe2kqwB6SQ+H9DezOfZaSLnmka1NtGm3fCQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + jest-worker@29.7.0: resolution: { @@ -4898,6 +5823,26 @@ packages: } engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + jest-worker@30.1.0: + resolution: + { + integrity: sha512-uvWcSjlwAAgIu133Tt77A05H7RIk3Ho8tZL50bQM2AkvLdluw9NG48lRCl3Dt+MOH719n/0nnb5YxUwcuJiKRA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest@30.1.3: + resolution: + { + integrity: sha512-Ry+p2+NLk6u8Agh5yVqELfUJvRfV51hhVBRIB5yZPY7mU0DGBmOuFG5GebZbMbm86cdQNK0fhJuDX8/1YorISQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + jiti@1.21.7: resolution: { @@ -4911,12 +5856,26 @@ packages: integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==, } + js-levenshtein@1.1.6: + resolution: + { + integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==, + } + engines: { node: ">=0.10.0" } + js-tokens@4.0.0: resolution: { integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, } + js-yaml@3.14.1: + resolution: + { + integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==, + } + hasBin: true + js-yaml@4.1.0: resolution: { @@ -4981,6 +5940,14 @@ packages: } hasBin: true + json5@2.2.3: + resolution: + { + integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, + } + engines: { node: ">=6" } + hasBin: true + jsonfile@4.0.0: resolution: { @@ -5019,6 +5986,13 @@ packages: } engines: { node: ">=0.10" } + leven@3.1.0: + resolution: + { + integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==, + } + engines: { node: ">=6" } + levn@0.4.1: resolution: { @@ -5057,6 +6031,13 @@ packages: integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==, } + locate-path@5.0.0: + resolution: + { + integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, + } + engines: { node: ">=8" } + locate-path@6.0.0: resolution: { @@ -5076,6 +6057,12 @@ packages: integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==, } + lodash.memoize@4.1.2: + resolution: + { + integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==, + } + lodash.merge@4.6.2: resolution: { @@ -5101,6 +6088,12 @@ packages: integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, } + lru-cache@5.1.1: + resolution: + { + integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, + } + lru-cache@6.0.0: resolution: { @@ -5130,12 +6123,25 @@ packages: } engines: { node: ">=8" } + make-dir@4.0.0: + resolution: + { + integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, + } + engines: { node: ">=10" } + make-error@1.3.6: resolution: { integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==, } + makeerror@1.0.12: + resolution: + { + integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==, + } + math-intrinsics@1.1.0: resolution: { @@ -5460,12 +6466,6 @@ packages: engines: { node: ">=10" } hasBin: true - mlly@1.7.4: - resolution: - { - integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==, - } - mri@1.2.0: resolution: { @@ -5566,24 +6566,12 @@ packages: sass: optional: true - node-abort-controller@3.1.1: - resolution: - { - integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==, - } - node-addon-api@7.1.1: resolution: { integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==, } - node-fetch-native@1.6.7: - resolution: - { - integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==, - } - node-fetch@2.6.7: resolution: { @@ -5627,6 +6615,12 @@ packages: } hasBin: true + node-int64@0.4.0: + resolution: + { + integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==, + } + node-releases@2.0.19: resolution: { @@ -5690,14 +6684,6 @@ packages: react-router-dom: optional: true - nypm@0.5.4: - resolution: - { - integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==, - } - engines: { node: ^14.16.0 || >=16.10.0 } - hasBin: true - oauth@0.9.15: resolution: { @@ -5774,12 +6760,6 @@ packages: } engines: { node: ">= 0.4" } - ohash@1.1.6: - resolution: - { - integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==, - } - oidc-token-hash@5.1.1: resolution: { @@ -5806,6 +6786,36 @@ packages: } engines: { node: ">=6" } + openapi-fetch@0.14.0: + resolution: + { + integrity: sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==, + } + + openapi-react-query@0.5.0: + resolution: + { + integrity: sha512-VtyqiamsbWsdSWtXmj/fAR+m9nNxztsof6h8ZIsjRj8c8UR/x9AIwHwd60IqwgymmFwo7qfSJQ1ZzMJrtqjQVg==, + } + peerDependencies: + "@tanstack/react-query": ^5.25.0 + openapi-fetch: ^0.14.0 + + openapi-typescript-helpers@0.0.15: + resolution: + { + integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==, + } + + openapi-typescript@7.9.1: + resolution: + { + integrity: sha512-9gJtoY04mk6iPMbToPjPxEAtfXZ0dTsMZtsgUI8YZta0btPPig9DJFP4jlerQD/7QOwYgb0tl+zLUpDf7vb7VA==, + } + hasBin: true + peerDependencies: + typescript: ^5.x + openid-client@5.7.1: resolution: { @@ -5840,6 +6850,13 @@ packages: } engines: { node: ">=8" } + p-limit@2.3.0: + resolution: + { + integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, + } + engines: { node: ">=6" } + p-limit@3.1.0: resolution: { @@ -5847,6 +6864,13 @@ packages: } engines: { node: ">=10" } + p-locate@4.1.0: + resolution: + { + integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==, + } + engines: { node: ">=8" } + p-locate@5.0.0: resolution: { @@ -5854,6 +6878,13 @@ packages: } engines: { node: ">=10" } + p-try@2.2.0: + resolution: + { + integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, + } + engines: { node: ">=6" } + package-json-from-dist@1.0.1: resolution: { @@ -5880,6 +6911,13 @@ packages: } engines: { node: ">=8" } + parse-json@8.3.0: + resolution: + { + integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==, + } + engines: { node: ">=18" } + parse-ms@2.1.0: resolution: { @@ -5959,30 +6997,12 @@ packages: } engines: { node: ">=8" } - pathe@1.1.2: - resolution: - { - integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==, - } - - pathe@2.0.3: - resolution: - { - integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, - } - pend@1.2.0: resolution: { integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==, } - perfect-debounce@1.0.0: - resolution: - { - integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==, - } - perfect-freehand@1.2.2: resolution: { @@ -6029,11 +7049,19 @@ packages: } engines: { node: ">= 6" } - pkg-types@1.3.1: + pkg-dir@4.2.0: resolution: { - integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==, + integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==, } + engines: { node: ">=8" } + + pluralize@8.0.0: + resolution: + { + integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==, + } + engines: { node: ">=4" } possible-typed-array-names@1.1.0: resolution: @@ -6146,6 +7174,13 @@ packages: integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==, } + pretty-format@30.0.5: + resolution: + { + integrity: sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + pretty-ms@7.0.1: resolution: { @@ -6209,6 +7244,12 @@ packages: } engines: { node: ">=6" } + pure-rand@7.0.1: + resolution: + { + integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==, + } + qr.js@0.0.0: resolution: { @@ -6234,12 +7275,6 @@ packages: } engines: { node: ">= 0.8" } - rc9@2.1.2: - resolution: - { - integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==, - } - react-dom@18.3.1: resolution: { @@ -6271,6 +7306,12 @@ packages: integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==, } + react-is@18.3.1: + resolution: + { + integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, + } + react-markdown@9.1.0: resolution: { @@ -6392,13 +7433,6 @@ packages: } engines: { node: ">=4" } - redlock@5.0.0-beta.2: - resolution: - { - integrity: sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw==, - } - engines: { node: ">=12" } - redux-thunk@3.1.0: resolution: { @@ -6439,6 +7473,13 @@ packages: integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==, } + require-directory@2.1.1: + resolution: + { + integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, + } + engines: { node: ">=0.10.0" } + require-from-string@2.0.2: resolution: { @@ -6458,6 +7499,13 @@ packages: integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==, } + resolve-cwd@3.0.0: + resolution: + { + integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==, + } + engines: { node: ">=8" } + resolve-from@4.0.0: resolution: { @@ -6727,6 +7775,13 @@ packages: integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==, } + slash@3.0.0: + resolution: + { + integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==, + } + engines: { node: ">=8" } + socket.io-client@4.7.2: resolution: { @@ -6748,6 +7803,12 @@ packages: } engines: { node: ">=0.10.0" } + source-map-support@0.5.13: + resolution: + { + integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==, + } + source-map@0.5.7: resolution: { @@ -6768,6 +7829,12 @@ packages: integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==, } + sprintf-js@1.0.3: + resolution: + { + integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==, + } + sprintf-js@1.1.3: resolution: { @@ -6780,6 +7847,13 @@ packages: integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==, } + stack-utils@2.0.6: + resolution: + { + integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==, + } + engines: { node: ">=10" } + stacktrace-parser@0.1.11: resolution: { @@ -6832,6 +7906,13 @@ packages: } engines: { node: ">=10.0.0" } + string-length@4.0.2: + resolution: + { + integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==, + } + engines: { node: ">=10" } + string-width@4.2.3: resolution: { @@ -6920,6 +8001,13 @@ packages: } engines: { node: ">=4" } + strip-bom@4.0.0: + resolution: + { + integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==, + } + engines: { node: ">=8" } + strip-final-newline@2.0.0: resolution: { @@ -6976,6 +8064,13 @@ packages: engines: { node: ">=16 || 14 >=14.17" } hasBin: true + supports-color@10.2.0: + resolution: + { + integrity: sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==, + } + engines: { node: ">=18" } + supports-color@7.2.0: resolution: { @@ -7004,6 +8099,13 @@ packages: } engines: { node: ">= 0.4" } + synckit@0.11.11: + resolution: + { + integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==, + } + engines: { node: ^14.18.0 || >=16.0.0 } + tailwindcss@3.4.17: resolution: { @@ -7026,6 +8128,13 @@ packages: } engines: { node: ">=10" } + test-exclude@6.0.0: + resolution: + { + integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==, + } + engines: { node: ">=8" } + thenify-all@1.6.0: resolution: { @@ -7046,12 +8155,6 @@ packages: } engines: { node: ">=10" } - tinyexec@0.3.2: - resolution: - { - integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, - } - tinyglobby@0.2.14: resolution: { @@ -7059,6 +8162,12 @@ packages: } engines: { node: ">=12.0.0" } + tmpl@1.0.5: + resolution: + { + integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==, + } + to-regex-range@5.0.1: resolution: { @@ -7113,6 +8222,36 @@ packages: integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==, } + ts-jest@29.4.1: + resolution: + { + integrity: sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0 } + hasBin: true + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 || ^30.0.0 + "@jest/types": ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: "*" + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: ">=4.3 <6" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + ts-morph@12.0.0: resolution: { @@ -7161,6 +8300,20 @@ packages: } engines: { node: ">= 0.8.0" } + type-detect@4.0.8: + resolution: + { + integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==, + } + engines: { node: ">=4" } + + type-fest@0.21.3: + resolution: + { + integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==, + } + engines: { node: ">=10" } + type-fest@0.7.1: resolution: { @@ -7168,6 +8321,13 @@ packages: } engines: { node: ">=8" } + type-fest@4.41.0: + resolution: + { + integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==, + } + engines: { node: ">=16" } + typed-array-buffer@1.0.3: resolution: { @@ -7244,12 +8404,6 @@ packages: integrity: sha512-v+Z8Jal+GtmKGtJ34GIQlCJAxrDt9kbjpNsNvYoAXFyr4gNfWlD4uJJuoNNu/0UTVaKvQwHaSU095YDl71lKPw==, } - ufo@1.6.1: - resolution: - { - integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==, - } - uglify-js@3.19.3: resolution: { @@ -7289,12 +8443,6 @@ packages: } engines: { node: ">= 0.4" } - uncrypto@0.1.3: - resolution: - { - integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==, - } - undici-types@7.10.0: resolution: { @@ -7386,6 +8534,12 @@ packages: integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==, } + uri-js-replace@1.0.1: + resolution: + { + integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==, + } + uri-js@4.4.1: resolution: { @@ -7464,6 +8618,13 @@ packages: integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==, } + v8-to-istanbul@9.3.0: + resolution: + { + integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==, + } + engines: { node: ">=10.12.0" } + vercel@37.14.0: resolution: { @@ -7484,6 +8645,12 @@ packages: integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, } + walker@1.0.8: + resolution: + { + integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==, + } + wavesurfer.js@7.10.1: resolution: { @@ -7597,6 +8764,13 @@ packages: integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, } + write-file-atomic@5.0.1: + resolution: + { + integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==, + } + engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + ws@8.17.1: resolution: { @@ -7633,6 +8807,13 @@ packages: } engines: { node: ">=0.4.0" } + y18n@5.0.8: + resolution: + { + integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, + } + engines: { node: ">=10" } + yallist@3.1.1: resolution: { @@ -7645,6 +8826,12 @@ packages: integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==, } + yaml-ast-parser@0.0.43: + resolution: + { + integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==, + } + yaml@1.10.2: resolution: { @@ -7660,6 +8847,20 @@ packages: engines: { node: ">= 14.6" } hasBin: true + yargs-parser@21.1.1: + resolution: + { + integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==, + } + engines: { node: ">=12" } + + yargs@17.7.2: + resolution: + { + integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==, + } + engines: { node: ">=12" } + yauzl-clone@1.0.4: resolution: { @@ -7694,6 +8895,12 @@ packages: } engines: { node: ">=10" } + zod@4.1.5: + resolution: + { + integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==, + } + zwitch@2.0.4: resolution: { @@ -7703,11 +8910,10 @@ packages: snapshots: "@alloc/quick-lru@5.2.0": {} - "@apidevtools/json-schema-ref-parser@11.6.4": + "@ampproject/remapping@2.3.0": dependencies: - "@jsdevtools/ono": 7.1.3 - "@types/json-schema": 7.0.15 - js-yaml: 4.1.0 + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.30 "@ark-ui/react@5.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: @@ -7779,6 +8985,28 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + "@babel/compat-data@7.28.0": {} + + "@babel/core@7.28.3": + dependencies: + "@ampproject/remapping": 2.3.0 + "@babel/code-frame": 7.27.1 + "@babel/generator": 7.28.3 + "@babel/helper-compilation-targets": 7.27.2 + "@babel/helper-module-transforms": 7.28.3(@babel/core@7.28.3) + "@babel/helpers": 7.28.3 + "@babel/parser": 7.28.3 + "@babel/template": 7.27.2 + "@babel/traverse": 7.28.3 + "@babel/types": 7.28.2 + convert-source-map: 2.0.0 + debug: 4.4.1(supports-color@9.4.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + "@babel/generator@7.28.0": dependencies: "@babel/parser": 7.28.0 @@ -7787,6 +9015,22 @@ snapshots: "@jridgewell/trace-mapping": 0.3.30 jsesc: 3.1.0 + "@babel/generator@7.28.3": + dependencies: + "@babel/parser": 7.28.3 + "@babel/types": 7.28.2 + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.30 + jsesc: 3.1.0 + + "@babel/helper-compilation-targets@7.27.2": + dependencies: + "@babel/compat-data": 7.28.0 + "@babel/helper-validator-option": 7.27.1 + browserslist: 4.25.2 + lru-cache: 5.1.1 + semver: 6.3.1 + "@babel/helper-globals@7.28.0": {} "@babel/helper-module-imports@7.27.1": @@ -7796,14 +9040,121 @@ snapshots: transitivePeerDependencies: - supports-color + "@babel/helper-module-transforms@7.28.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-module-imports": 7.27.1 + "@babel/helper-validator-identifier": 7.27.1 + "@babel/traverse": 7.28.3 + transitivePeerDependencies: + - supports-color + + "@babel/helper-plugin-utils@7.27.1": {} + "@babel/helper-string-parser@7.27.1": {} "@babel/helper-validator-identifier@7.27.1": {} + "@babel/helper-validator-option@7.27.1": {} + + "@babel/helpers@7.28.3": + dependencies: + "@babel/template": 7.27.2 + "@babel/types": 7.28.2 + "@babel/parser@7.28.0": dependencies: "@babel/types": 7.28.2 + "@babel/parser@7.28.3": + dependencies: + "@babel/types": 7.28.2 + + "@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + + "@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.3)": + dependencies: + "@babel/core": 7.28.3 + "@babel/helper-plugin-utils": 7.27.1 + "@babel/runtime@7.28.2": {} "@babel/template@7.27.2": @@ -7824,11 +9175,25 @@ snapshots: transitivePeerDependencies: - supports-color + "@babel/traverse@7.28.3": + dependencies: + "@babel/code-frame": 7.27.1 + "@babel/generator": 7.28.3 + "@babel/helper-globals": 7.28.0 + "@babel/parser": 7.28.3 + "@babel/template": 7.27.2 + "@babel/types": 7.28.2 + debug: 4.4.1(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + "@babel/types@7.28.2": dependencies: "@babel/helper-string-parser": 7.27.1 "@babel/helper-validator-identifier": 7.27.1 + "@bcoe/v8-coverage@0.2.3": {} + "@chakra-ui/react@3.24.2(@emotion/react@11.14.0(@types/react@18.2.20)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": dependencies: "@ark-ui/react": 5.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -8027,17 +9392,6 @@ snapshots: prop-types: 15.8.1 react: 18.3.1 - "@hey-api/openapi-ts@0.48.3(typescript@5.9.2)": - dependencies: - "@apidevtools/json-schema-ref-parser": 11.6.4 - c12: 1.11.1 - camelcase: 8.0.0 - commander: 12.1.0 - handlebars: 4.7.8 - typescript: 5.9.2 - transitivePeerDependencies: - - magicast - "@humanfs/core@0.19.1": {} "@humanfs/node@0.16.6": @@ -8059,7 +9413,7 @@ snapshots: dependencies: "@swc/helpers": 0.5.17 - "@ioredis/commands@1.3.0": {} + "@ioredis/commands@1.3.1": {} "@isaacs/cliui@8.0.2": dependencies: @@ -8070,10 +9424,189 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + "@istanbuljs/load-nyc-config@1.1.0": + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + "@istanbuljs/schema@0.1.3": {} + + "@jest/console@30.1.2": + dependencies: + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + jest-message-util: 30.1.0 + jest-util: 30.0.5 + slash: 3.0.0 + + "@jest/core@30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))": + dependencies: + "@jest/console": 30.1.2 + "@jest/pattern": 30.0.1 + "@jest/reporters": 30.1.3 + "@jest/test-result": 30.1.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.0 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.0.5 + jest-config: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + jest-haste-map: 30.1.0 + jest-message-util: 30.1.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.1.3 + jest-resolve-dependencies: 30.1.3 + jest-runner: 30.1.3 + jest-runtime: 30.1.3 + jest-snapshot: 30.1.2 + jest-util: 30.0.5 + jest-validate: 30.1.0 + jest-watcher: 30.1.3 + micromatch: 4.0.8 + pretty-format: 30.0.5 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + "@jest/diff-sequences@30.0.1": {} + + "@jest/environment@30.1.2": + dependencies: + "@jest/fake-timers": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + jest-mock: 30.0.5 + + "@jest/expect-utils@30.1.2": + dependencies: + "@jest/get-type": 30.1.0 + + "@jest/expect@30.1.2": + dependencies: + expect: 30.1.2 + jest-snapshot: 30.1.2 + transitivePeerDependencies: + - supports-color + + "@jest/fake-timers@30.1.2": + dependencies: + "@jest/types": 30.0.5 + "@sinonjs/fake-timers": 13.0.5 + "@types/node": 24.2.1 + jest-message-util: 30.1.0 + jest-mock: 30.0.5 + jest-util: 30.0.5 + + "@jest/get-type@30.1.0": {} + + "@jest/globals@30.1.2": + dependencies: + "@jest/environment": 30.1.2 + "@jest/expect": 30.1.2 + "@jest/types": 30.0.5 + jest-mock: 30.0.5 + transitivePeerDependencies: + - supports-color + + "@jest/pattern@30.0.1": + dependencies: + "@types/node": 24.2.1 + jest-regex-util: 30.0.1 + + "@jest/reporters@30.1.3": + dependencies: + "@bcoe/v8-coverage": 0.2.3 + "@jest/console": 30.1.2 + "@jest/test-result": 30.1.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + "@jridgewell/trace-mapping": 0.3.30 + "@types/node": 24.2.1 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit-x: 0.2.2 + glob: 10.4.5 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.1.0 + jest-util: 30.0.5 + jest-worker: 30.1.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + "@jest/schemas@29.6.3": dependencies: "@sinclair/typebox": 0.27.8 + "@jest/schemas@30.0.5": + dependencies: + "@sinclair/typebox": 0.34.41 + + "@jest/snapshot-utils@30.1.2": + dependencies: + "@jest/types": 30.0.5 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + "@jest/source-map@30.0.1": + dependencies: + "@jridgewell/trace-mapping": 0.3.30 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + "@jest/test-result@30.1.3": + dependencies: + "@jest/console": 30.1.2 + "@jest/types": 30.0.5 + "@types/istanbul-lib-coverage": 2.0.6 + collect-v8-coverage: 1.0.2 + + "@jest/test-sequencer@30.1.3": + dependencies: + "@jest/test-result": 30.1.3 + graceful-fs: 4.2.11 + jest-haste-map: 30.1.0 + slash: 3.0.0 + + "@jest/transform@30.1.2": + dependencies: + "@babel/core": 7.28.3 + "@jest/types": 30.0.5 + "@jridgewell/trace-mapping": 0.3.30 + babel-plugin-istanbul: 7.0.0 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.1.0 + jest-regex-util: 30.0.1 + jest-util: 30.0.5 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + "@jest/types@29.6.3": dependencies: "@jest/schemas": 29.6.3 @@ -8083,6 +9616,16 @@ snapshots: "@types/yargs": 17.0.33 chalk: 4.1.2 + "@jest/types@30.0.5": + dependencies: + "@jest/pattern": 30.0.1 + "@jest/schemas": 30.0.5 + "@types/istanbul-lib-coverage": 2.0.6 + "@types/istanbul-reports": 3.0.4 + "@types/node": 24.2.1 + "@types/yargs": 17.0.33 + chalk: 4.1.2 + "@jridgewell/gen-mapping@0.3.13": dependencies: "@jridgewell/sourcemap-codec": 1.5.5 @@ -8102,8 +9645,6 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 - "@jsdevtools/ono@7.1.3": {} - "@mapbox/node-pre-gyp@1.0.11": dependencies: detect-libc: 2.0.4 @@ -8241,6 +9782,8 @@ snapshots: "@pkgjs/parseargs@0.11.0": optional: true + "@pkgr/core@0.2.9": {} + "@radix-ui/primitive@1.1.3": {} "@radix-ui/react-arrow@1.1.7(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": @@ -8420,6 +9963,29 @@ snapshots: "@radix-ui/rect@1.1.1": {} + "@redocly/ajv@8.11.3": + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + "@redocly/config@0.22.2": {} + + "@redocly/openapi-core@1.34.5(supports-color@10.2.0)": + dependencies: + "@redocly/ajv": 8.11.3 + "@redocly/config": 0.22.2 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.0) + js-levenshtein: 1.1.6 + js-yaml: 4.1.0 + minimatch: 5.1.6 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + "@reduxjs/toolkit@2.8.2(react@18.3.1)": dependencies: "@standard-schema/spec": 1.0.0 @@ -8513,7 +10079,7 @@ snapshots: "@sentry/utils": 7.120.4 localforage: 1.10.0 - "@sentry/nextjs@7.120.4(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)": + "@sentry/nextjs@7.120.4(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)": dependencies: "@rollup/plugin-commonjs": 24.0.0(rollup@2.79.2) "@sentry/core": 7.120.4 @@ -8525,7 +10091,7 @@ snapshots: "@sentry/vercel-edge": 7.120.4 "@sentry/webpack-plugin": 1.21.0 chalk: 3.0.0 - next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) react: 18.3.1 resolve: 1.22.8 rollup: 2.79.2 @@ -8584,6 +10150,16 @@ snapshots: "@sinclair/typebox@0.27.8": {} + "@sinclair/typebox@0.34.41": {} + + "@sinonjs/commons@3.0.1": + dependencies: + type-detect: 4.0.8 + + "@sinonjs/fake-timers@13.0.5": + dependencies: + "@sinonjs/commons": 3.0.1 + "@socket.io/component-emitter@3.1.2": {} "@standard-schema/spec@1.0.0": {} @@ -8601,6 +10177,13 @@ snapshots: "@swc/counter": 0.1.3 tslib: 2.8.1 + "@tanstack/query-core@5.85.9": {} + + "@tanstack/react-query@5.85.9(react@18.3.1)": + dependencies: + "@tanstack/query-core": 5.85.9 + react: 18.3.1 + "@tootallnate/once@2.0.0": {} "@ts-morph/common@0.11.1": @@ -8623,6 +10206,27 @@ snapshots: tslib: 2.8.1 optional: true + "@types/babel__core@7.20.5": + dependencies: + "@babel/parser": 7.28.0 + "@babel/types": 7.28.2 + "@types/babel__generator": 7.27.0 + "@types/babel__template": 7.4.4 + "@types/babel__traverse": 7.28.0 + + "@types/babel__generator@7.27.0": + dependencies: + "@babel/types": 7.28.2 + + "@types/babel__template@7.4.4": + dependencies: + "@babel/parser": 7.28.0 + "@babel/types": 7.28.2 + + "@types/babel__traverse@7.28.0": + dependencies: + "@babel/types": 7.28.2 + "@types/debug@4.1.12": dependencies: "@types/ms": 2.1.0 @@ -8639,6 +10243,12 @@ snapshots: dependencies: "@types/unist": 3.0.3 + "@types/ioredis@5.0.0": + dependencies: + ioredis: 5.7.0 + transitivePeerDependencies: + - supports-color + "@types/istanbul-lib-coverage@2.0.6": {} "@types/istanbul-lib-report@3.0.3": @@ -8649,6 +10259,11 @@ snapshots: dependencies: "@types/istanbul-lib-report": 3.0.3 + "@types/jest@30.0.0": + dependencies: + expect: 30.1.2 + pretty-format: 30.0.5 + "@types/json-schema@7.0.15": {} "@types/json5@0.0.29": {} @@ -8682,6 +10297,8 @@ snapshots: "@types/scheduler@0.26.0": {} + "@types/stack-utils@2.0.3": {} + "@types/ua-parser-js@0.7.39": {} "@types/unist@2.0.11": {} @@ -8860,10 +10477,6 @@ snapshots: "@unrs/resolver-binding-win32-x64-msvc@1.11.1": optional: true - "@upstash/redis@1.35.3": - dependencies: - uncrypto: 0.1.3 - "@vercel/build-utils@8.4.12": {} "@vercel/edge-config-fs@0.1.0": {} @@ -8920,10 +10533,6 @@ snapshots: "@vercel/static-config": 3.0.0 ts-morph: 12.0.0 - "@vercel/kv@2.0.0": - dependencies: - "@upstash/redis": 1.35.3 - "@vercel/next@4.3.18": dependencies: "@vercel/nft": 0.27.3 @@ -9601,6 +11210,8 @@ snapshots: transitivePeerDependencies: - supports-color + agent-base@7.1.4: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -9615,6 +11226,12 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + ansi-regex@5.0.1: {} ansi-regex@6.1.0: {} @@ -9623,6 +11240,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -9645,6 +11264,10 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -9771,12 +11394,66 @@ snapshots: axobject-query@4.1.0: {} + babel-jest@30.1.2(@babel/core@7.28.3): + dependencies: + "@babel/core": 7.28.3 + "@jest/transform": 30.1.2 + "@types/babel__core": 7.20.5 + babel-plugin-istanbul: 7.0.0 + babel-preset-jest: 30.0.1(@babel/core@7.28.3) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.0: + dependencies: + "@babel/helper-plugin-utils": 7.27.1 + "@istanbuljs/load-nyc-config": 1.1.0 + "@istanbuljs/schema": 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.0.1: + dependencies: + "@babel/template": 7.27.2 + "@babel/types": 7.28.2 + "@types/babel__core": 7.20.5 + babel-plugin-macros@3.1.0: dependencies: "@babel/runtime": 7.28.2 cosmiconfig: 7.1.0 resolve: 1.22.10 + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.3): + dependencies: + "@babel/core": 7.28.3 + "@babel/plugin-syntax-async-generators": 7.8.4(@babel/core@7.28.3) + "@babel/plugin-syntax-bigint": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-class-properties": 7.12.13(@babel/core@7.28.3) + "@babel/plugin-syntax-class-static-block": 7.14.5(@babel/core@7.28.3) + "@babel/plugin-syntax-import-attributes": 7.27.1(@babel/core@7.28.3) + "@babel/plugin-syntax-import-meta": 7.10.4(@babel/core@7.28.3) + "@babel/plugin-syntax-json-strings": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-logical-assignment-operators": 7.10.4(@babel/core@7.28.3) + "@babel/plugin-syntax-nullish-coalescing-operator": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-numeric-separator": 7.10.4(@babel/core@7.28.3) + "@babel/plugin-syntax-object-rest-spread": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-optional-catch-binding": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-optional-chaining": 7.8.3(@babel/core@7.28.3) + "@babel/plugin-syntax-private-property-in-object": 7.14.5(@babel/core@7.28.3) + "@babel/plugin-syntax-top-level-await": 7.14.5(@babel/core@7.28.3) + + babel-preset-jest@30.0.1(@babel/core@7.28.3): + dependencies: + "@babel/core": 7.28.3 + babel-plugin-jest-hoist: 30.0.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.3) + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -9809,10 +11486,20 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.2) + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + btoa@1.2.1: {} buffer-crc32@0.2.13: {} + buffer-from@1.1.2: {} + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -9824,21 +11511,6 @@ snapshots: bytes@3.1.0: {} - c12@1.11.1: - dependencies: - chokidar: 3.6.0 - confbox: 0.1.8 - defu: 6.1.4 - dotenv: 16.6.1 - giget: 1.2.5 - jiti: 1.21.7 - mlly: 1.7.4 - ohash: 1.1.6 - pathe: 1.1.2 - perfect-debounce: 1.0.0 - pkg-types: 1.3.1 - rc9: 2.1.2 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -9860,7 +11532,9 @@ snapshots: camelcase-css@2.0.1: {} - camelcase@8.0.0: {} + camelcase@5.3.1: {} + + camelcase@6.3.0: {} caniuse-lite@1.0.30001734: {} @@ -9876,6 +11550,10 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + + char-regex@1.0.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -9922,22 +11600,32 @@ snapshots: ci-info@3.9.0: {} - citty@0.1.6: - dependencies: - consola: 3.4.2 + ci-info@4.3.0: {} cjs-module-lexer@1.2.3: {} + cjs-module-lexer@2.1.0: {} + classnames@2.5.1: {} client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cluster-key-slot@1.1.2: {} + co@4.6.0: {} + code-block-writer@10.1.1: {} + collect-v8-coverage@1.0.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -9946,24 +11634,20 @@ snapshots: color-support@1.1.3: {} + colorette@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 comma-separated-tokens@2.0.3: {} - commander@12.1.0: {} - commander@4.1.1: {} commondir@1.0.1: {} concat-map@0.0.1: {} - confbox@0.1.8: {} - - consola@3.4.2: {} - console-control-strings@1.1.0: {} content-type@1.0.4: {} @@ -9972,6 +11656,8 @@ snapshots: convert-source-map@1.9.0: {} + convert-source-map@2.0.0: {} + cookie@0.7.2: {} cosmiconfig@7.1.0: @@ -10026,6 +11712,12 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.1(supports-color@10.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.0 + debug@4.4.1(supports-color@9.4.0): dependencies: ms: 2.1.3 @@ -10036,8 +11728,14 @@ snapshots: dependencies: character-entities: 2.0.2 + dedent@1.7.0(babel-plugin-macros@3.1.0): + optionalDependencies: + babel-plugin-macros: 3.1.0 + deep-is@0.1.4: {} + deepmerge@4.3.1: {} + define-data-property@1.1.4: dependencies: es-define-property: 1.0.1 @@ -10050,8 +11748,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - defu@6.1.4: {} - delayed-stream@1.0.0: {} delegates@1.0.0: {} @@ -10062,8 +11758,6 @@ snapshots: dequal@2.0.3: {} - destr@2.0.5: {} - detect-europe-js@0.1.2: {} detect-libc@1.0.3: @@ -10071,6 +11765,8 @@ snapshots: detect-libc@2.0.4: {} + detect-newline@3.1.0: {} + detect-node-es@1.1.0: {} devlop@1.1.0: @@ -10103,8 +11799,6 @@ snapshots: domsanitizer: 0.2.3 umap: 1.0.2 - dotenv@16.6.1: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10127,6 +11821,8 @@ snapshots: electron-to-chromium@1.5.200: {} + emittery@0.13.1: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -10347,6 +12043,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} eslint-config-next@14.2.31(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2): @@ -10534,6 +12232,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -10571,6 +12271,29 @@ snapshots: signal-exit: 3.0.7 strip-final-newline: 2.0.0 + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + + expect@30.1.2: + dependencies: + "@jest/expect-utils": 30.1.2 + "@jest/get-type": 30.1.0 + jest-matcher-utils: 30.1.2 + jest-message-util: 30.1.0 + jest-mock: 30.0.5 + jest-util: 30.0.5 + extend@3.0.2: {} fake-mediastreamtrack@1.2.0: @@ -10598,6 +12321,10 @@ snapshots: dependencies: reusify: 1.1.0 + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -10618,6 +12345,11 @@ snapshots: find-root@1.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -10708,8 +12440,12 @@ snapshots: generic-pool@3.4.2: {} + gensync@1.0.0-beta.2: {} + get-browser-rtc@1.1.0: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10725,6 +12461,8 @@ snapshots: get-nonce@1.0.1: {} + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -10734,6 +12472,8 @@ snapshots: dependencies: pump: 3.0.3 + get-stream@6.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -10744,16 +12484,6 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - giget@1.2.5: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - defu: 6.1.4 - node-fetch-native: 1.6.7 - nypm: 0.5.4 - pathe: 2.0.3 - tar: 6.2.1 - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -10885,6 +12615,8 @@ snapshots: dependencies: react-is: 16.13.1 + html-escaper@2.0.2: {} + html-url-attributes@3.0.1: {} http-errors@1.4.0: @@ -10907,8 +12639,17 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6(supports-color@10.2.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.1(supports-color@10.2.0) + transitivePeerDependencies: + - supports-color + human-signals@1.1.1: {} + human-signals@2.1.0: {} + hyperhtml-style@0.1.3: {} iconv-lite@0.4.24: @@ -10932,8 +12673,15 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + imurmurhash@0.1.4: {} + index-to-position@1.1.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -10953,7 +12701,7 @@ snapshots: ioredis@5.7.0: dependencies: - "@ioredis/commands": 1.3.0 + "@ioredis/commands": 1.3.1 cluster-key-slot: 1.1.2 debug: 4.4.1(supports-color@9.4.0) denque: 2.1.0 @@ -11043,6 +12791,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-generator-fn@2.1.0: {} + is-generator-function@1.1.0: dependencies: call-bound: 1.0.4 @@ -11122,6 +12872,37 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + "@babel/core": 7.28.3 + "@babel/parser": 7.28.0 + "@istanbuljs/schema": 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.2 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + "@jridgewell/trace-mapping": 0.3.30 + debug: 4.4.1(supports-color@9.4.0) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -11143,6 +12924,301 @@ snapshots: optionalDependencies: "@pkgjs/parseargs": 0.11.0 + jest-changed-files@30.0.5: + dependencies: + execa: 5.1.1 + jest-util: 30.0.5 + p-limit: 3.1.0 + + jest-circus@30.1.3(babel-plugin-macros@3.1.0): + dependencies: + "@jest/environment": 30.1.2 + "@jest/expect": 30.1.2 + "@jest/test-result": 30.1.3 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.0(babel-plugin-macros@3.1.0) + is-generator-fn: 2.1.0 + jest-each: 30.1.0 + jest-matcher-utils: 30.1.2 + jest-message-util: 30.1.0 + jest-runtime: 30.1.3 + jest-snapshot: 30.1.2 + jest-util: 30.0.5 + p-limit: 3.1.0 + pretty-format: 30.0.5 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)): + dependencies: + "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + "@jest/test-result": 30.1.3 + "@jest/types": 30.0.5 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + jest-util: 30.0.5 + jest-validate: 30.1.0 + yargs: 17.7.2 + transitivePeerDependencies: + - "@types/node" + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)): + dependencies: + "@babel/core": 7.28.3 + "@jest/get-type": 30.1.0 + "@jest/pattern": 30.0.1 + "@jest/test-sequencer": 30.1.3 + "@jest/types": 30.0.5 + babel-jest: 30.1.2(@babel/core@7.28.3) + chalk: 4.1.2 + ci-info: 4.3.0 + deepmerge: 4.3.1 + glob: 10.4.5 + graceful-fs: 4.2.11 + jest-circus: 30.1.3(babel-plugin-macros@3.1.0) + jest-docblock: 30.0.1 + jest-environment-node: 30.1.2 + jest-regex-util: 30.0.1 + jest-resolve: 30.1.3 + jest-runner: 30.1.3 + jest-util: 30.0.5 + jest-validate: 30.1.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.0.5 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + "@types/node": 16.18.11 + ts-node: 10.9.1(@types/node@16.18.11)(typescript@5.9.2) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)): + dependencies: + "@babel/core": 7.28.3 + "@jest/get-type": 30.1.0 + "@jest/pattern": 30.0.1 + "@jest/test-sequencer": 30.1.3 + "@jest/types": 30.0.5 + babel-jest: 30.1.2(@babel/core@7.28.3) + chalk: 4.1.2 + ci-info: 4.3.0 + deepmerge: 4.3.1 + glob: 10.4.5 + graceful-fs: 4.2.11 + jest-circus: 30.1.3(babel-plugin-macros@3.1.0) + jest-docblock: 30.0.1 + jest-environment-node: 30.1.2 + jest-regex-util: 30.0.1 + jest-resolve: 30.1.3 + jest-runner: 30.1.3 + jest-util: 30.0.5 + jest-validate: 30.1.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.0.5 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + "@types/node": 24.2.1 + ts-node: 10.9.1(@types/node@16.18.11)(typescript@5.9.2) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.1.2: + dependencies: + "@jest/diff-sequences": 30.0.1 + "@jest/get-type": 30.1.0 + chalk: 4.1.2 + pretty-format: 30.0.5 + + jest-docblock@30.0.1: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.1.0: + dependencies: + "@jest/get-type": 30.1.0 + "@jest/types": 30.0.5 + chalk: 4.1.2 + jest-util: 30.0.5 + pretty-format: 30.0.5 + + jest-environment-node@30.1.2: + dependencies: + "@jest/environment": 30.1.2 + "@jest/fake-timers": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + jest-mock: 30.0.5 + jest-util: 30.0.5 + jest-validate: 30.1.0 + + jest-haste-map@30.1.0: + dependencies: + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.0.5 + jest-worker: 30.1.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.1.0: + dependencies: + "@jest/get-type": 30.1.0 + pretty-format: 30.0.5 + + jest-matcher-utils@30.1.2: + dependencies: + "@jest/get-type": 30.1.0 + chalk: 4.1.2 + jest-diff: 30.1.2 + pretty-format: 30.0.5 + + jest-message-util@30.1.0: + dependencies: + "@babel/code-frame": 7.27.1 + "@jest/types": 30.0.5 + "@types/stack-utils": 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.0.5 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.0.5: + dependencies: + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + jest-util: 30.0.5 + + jest-pnp-resolver@1.2.3(jest-resolve@30.1.3): + optionalDependencies: + jest-resolve: 30.1.3 + + jest-regex-util@30.0.1: {} + + jest-resolve-dependencies@30.1.3: + dependencies: + jest-regex-util: 30.0.1 + jest-snapshot: 30.1.2 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.1.3: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.1.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.1.3) + jest-util: 30.0.5 + jest-validate: 30.1.0 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.1.3: + dependencies: + "@jest/console": 30.1.2 + "@jest/environment": 30.1.2 + "@jest/test-result": 30.1.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.0.1 + jest-environment-node: 30.1.2 + jest-haste-map: 30.1.0 + jest-leak-detector: 30.1.0 + jest-message-util: 30.1.0 + jest-resolve: 30.1.3 + jest-runtime: 30.1.3 + jest-util: 30.0.5 + jest-watcher: 30.1.3 + jest-worker: 30.1.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.1.3: + dependencies: + "@jest/environment": 30.1.2 + "@jest/fake-timers": 30.1.2 + "@jest/globals": 30.1.2 + "@jest/source-map": 30.0.1 + "@jest/test-result": 30.1.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + cjs-module-lexer: 2.1.0 + collect-v8-coverage: 1.0.2 + glob: 10.4.5 + graceful-fs: 4.2.11 + jest-haste-map: 30.1.0 + jest-message-util: 30.1.0 + jest-mock: 30.0.5 + jest-regex-util: 30.0.1 + jest-resolve: 30.1.3 + jest-snapshot: 30.1.2 + jest-util: 30.0.5 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.1.2: + dependencies: + "@babel/core": 7.28.3 + "@babel/generator": 7.28.0 + "@babel/plugin-syntax-jsx": 7.27.1(@babel/core@7.28.3) + "@babel/plugin-syntax-typescript": 7.27.1(@babel/core@7.28.3) + "@babel/types": 7.28.2 + "@jest/expect-utils": 30.1.2 + "@jest/get-type": 30.1.0 + "@jest/snapshot-utils": 30.1.2 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.3) + chalk: 4.1.2 + expect: 30.1.2 + graceful-fs: 4.2.11 + jest-diff: 30.1.2 + jest-matcher-utils: 30.1.2 + jest-message-util: 30.1.0 + jest-util: 30.0.5 + pretty-format: 30.0.5 + semver: 7.7.2 + synckit: 0.11.11 + transitivePeerDependencies: + - supports-color + jest-util@29.7.0: dependencies: "@jest/types": 29.6.3 @@ -11152,6 +13228,35 @@ snapshots: graceful-fs: 4.2.11 picomatch: 2.3.1 + jest-util@30.0.5: + dependencies: + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + chalk: 4.1.2 + ci-info: 4.3.0 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + + jest-validate@30.1.0: + dependencies: + "@jest/get-type": 30.1.0 + "@jest/types": 30.0.5 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.0.5 + + jest-watcher@30.1.3: + dependencies: + "@jest/test-result": 30.1.3 + "@jest/types": 30.0.5 + "@types/node": 24.2.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.0.5 + string-length: 4.0.2 + jest-worker@29.7.0: dependencies: "@types/node": 24.2.1 @@ -11159,12 +13264,40 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest-worker@30.1.0: + dependencies: + "@types/node": 24.2.1 + "@ungap/structured-clone": 1.3.0 + jest-util: 30.0.5 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)): + dependencies: + "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + "@jest/types": 30.0.5 + import-local: 3.2.0 + jest-cli: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + transitivePeerDependencies: + - "@types/node" + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jiti@1.21.7: {} jose@4.15.9: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -11192,6 +13325,8 @@ snapshots: dependencies: minimist: 1.2.8 + json5@2.2.3: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -11219,6 +13354,8 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + leven@3.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -11248,6 +13385,10 @@ snapshots: dependencies: lie: 3.1.1 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -11256,6 +13397,8 @@ snapshots: lodash.isarguments@3.1.0: {} + lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} longest-streak@3.1.0: {} @@ -11266,6 +13409,10 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lru-cache@6.0.0: dependencies: yallist: 4.0.0 @@ -11282,8 +13429,16 @@ snapshots: dependencies: semver: 6.3.1 + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + make-error@1.3.6: {} + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + math-intrinsics@1.1.0: {} mdast-util-from-markdown@2.0.2: @@ -11591,13 +13746,6 @@ snapshots: mkdirp@1.0.4: {} - mlly@1.7.4: - dependencies: - acorn: 8.15.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.1 - mri@1.2.0: {} ms@2.1.1: {} @@ -11618,13 +13766,13 @@ snapshots: neo-async@2.6.2: {} - next-auth@4.24.11(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-auth@4.24.11(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: "@babel/runtime": 7.28.2 "@panva/hkdf": 1.2.1 cookie: 0.7.2 jose: 4.15.9 - next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) + next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) oauth: 0.9.15 openid-client: 5.7.1 preact: 10.27.0 @@ -11638,7 +13786,7 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0): + next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0): dependencies: "@next/env": 14.2.31 "@swc/helpers": 0.5.5 @@ -11648,7 +13796,7 @@ snapshots: postcss: 8.4.31 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(react@18.3.1) + styled-jsx: 5.1.1(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1) optionalDependencies: "@next/swc-darwin-arm64": 14.2.31 "@next/swc-darwin-x64": 14.2.31 @@ -11664,13 +13812,9 @@ snapshots: - "@babel/core" - babel-plugin-macros - node-abort-controller@3.1.1: {} - node-addon-api@7.1.1: optional: true - node-fetch-native@1.6.7: {} - node-fetch@2.6.7: dependencies: whatwg-url: 5.0.0 @@ -11685,6 +13829,8 @@ snapshots: node-gyp-build@4.8.4: {} + node-int64@0.4.0: {} + node-releases@2.0.19: {} nopt@5.0.0: @@ -11706,21 +13852,12 @@ snapshots: gauge: 3.0.2 set-blocking: 2.0.0 - nuqs@2.4.3(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1): + nuqs@2.4.3(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1): dependencies: mitt: 3.0.1 react: 18.3.1 optionalDependencies: - next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) - - nypm@0.5.4: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - pathe: 2.0.3 - pkg-types: 1.3.1 - tinyexec: 0.3.2 - ufo: 1.6.1 + next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0) oauth@0.9.15: {} @@ -11770,8 +13907,6 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - ohash@1.1.6: {} - oidc-token-hash@5.1.1: {} once@1.3.3: @@ -11786,6 +13921,28 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openapi-fetch@0.14.0: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-react-query@0.5.0(@tanstack/react-query@5.85.9(react@18.3.1))(openapi-fetch@0.14.0): + dependencies: + "@tanstack/react-query": 5.85.9(react@18.3.1) + openapi-fetch: 0.14.0 + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.9.1(typescript@5.9.2): + dependencies: + "@redocly/openapi-core": 1.34.5(supports-color@10.2.0) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.0 + typescript: 5.9.2 + yargs-parser: 21.1.1 + openid-client@5.7.1: dependencies: jose: 4.15.9 @@ -11812,14 +13969,24 @@ snapshots: p-finally@2.0.1: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -11843,6 +14010,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + "@babel/code-frame": 7.27.1 + index-to-position: 1.1.0 + type-fest: 4.41.0 + parse-ms@2.1.0: {} path-browserify@1.0.1: {} @@ -11875,14 +14048,8 @@ snapshots: path-type@4.0.0: {} - pathe@1.1.2: {} - - pathe@2.0.3: {} - pend@1.2.0: {} - perfect-debounce@1.0.0: {} - perfect-freehand@1.2.2: {} picocolors@1.0.0: {} @@ -11897,11 +14064,11 @@ snapshots: pirates@4.0.7: {} - pkg-types@1.3.1: + pkg-dir@4.2.0: dependencies: - confbox: 0.1.8 - mlly: 1.7.4 - pathe: 2.0.3 + find-up: 4.1.0 + + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} @@ -11962,6 +14129,12 @@ snapshots: pretty-format@3.8.0: {} + pretty-format@30.0.5: + dependencies: + "@jest/schemas": 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + pretty-ms@7.0.1: dependencies: parse-ms: 2.1.0 @@ -11993,6 +14166,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@7.0.1: {} + qr.js@0.0.0: {} queue-microtask@1.2.3: {} @@ -12008,11 +14183,6 @@ snapshots: iconv-lite: 0.4.24 unpipe: 1.0.0 - rc9@2.1.2: - dependencies: - defu: 6.1.4 - destr: 2.0.5 - react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -12031,6 +14201,8 @@ snapshots: react-is@16.13.1: {} + react-is@18.3.1: {} + react-markdown@9.1.0(@types/react@18.2.20)(react@18.3.1): dependencies: "@types/hast": 3.0.4 @@ -12118,10 +14290,6 @@ snapshots: dependencies: redis-errors: 1.2.0 - redlock@5.0.0-beta.2: - dependencies: - node-abort-controller: 3.1.1 - redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 @@ -12165,12 +14333,18 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + require-directory@2.1.1: {} + require-from-string@2.0.2: {} reraf@1.1.1: {} reselect@5.1.1: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -12339,6 +14513,8 @@ snapshots: transitivePeerDependencies: - supports-color + slash@3.0.0: {} + socket.io-client@4.7.2: dependencies: "@socket.io/component-emitter": 3.1.2 @@ -12359,16 +14535,27 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map@0.5.7: {} source-map@0.6.1: {} space-separated-tokens@2.0.2: {} + sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} stable-hash@0.0.5: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stacktrace-parser@0.1.11: dependencies: type-fest: 0.7.1 @@ -12396,6 +14583,11 @@ snapshots: streamsearch@1.1.0: {} + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -12477,6 +14669,8 @@ snapshots: strip-bom@3.0.0: {} + strip-bom@4.0.0: {} + strip-final-newline@2.0.0: {} strip-json-comments@3.1.1: {} @@ -12489,10 +14683,13 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.1(react@18.3.1): + styled-jsx@5.1.1(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 react: 18.3.1 + optionalDependencies: + "@babel/core": 7.28.3 + babel-plugin-macros: 3.1.0 stylis@4.2.0: {} @@ -12506,6 +14703,8 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 + supports-color@10.2.0: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -12518,6 +14717,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + synckit@0.11.11: + dependencies: + "@pkgr/core": 0.2.9 + tailwindcss@3.4.17(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)): dependencies: "@alloc/quick-lru": 5.2.0 @@ -12564,6 +14767,12 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + test-exclude@6.0.0: + dependencies: + "@istanbuljs/schema": 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -12576,13 +14785,13 @@ snapshots: dependencies: convert-hrtime: 3.0.0 - tinyexec@0.3.2: {} - tinyglobby@0.2.14: dependencies: fdir: 6.4.6(picomatch@4.0.3) picomatch: 4.0.3 + tmpl@1.0.5: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -12603,6 +14812,26 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-jest@29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)))(typescript@5.9.2): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.2 + type-fest: 4.41.0 + typescript: 5.9.2 + yargs-parser: 21.1.1 + optionalDependencies: + "@babel/core": 7.28.3 + "@jest/transform": 30.1.2 + "@jest/types": 30.0.5 + babel-jest: 30.1.2(@babel/core@7.28.3) + jest-util: 30.0.5 + ts-morph@12.0.0: dependencies: "@ts-morph/common": 0.11.1 @@ -12660,8 +14889,14 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + type-fest@0.7.1: {} + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -12717,8 +14952,6 @@ snapshots: udomdiff@1.1.2: {} - ufo@1.6.1: {} - uglify-js@3.19.3: optional: true @@ -12739,8 +14972,6 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - uncrypto@0.1.3: {} - undici-types@7.10.0: {} undici@5.28.4: @@ -12818,6 +15049,8 @@ snapshots: uqr@0.1.2: {} + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -12853,6 +15086,12 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.3.0: + dependencies: + "@jridgewell/trace-mapping": 0.3.30 + "@types/istanbul-lib-coverage": 2.0.6 + convert-source-map: 2.0.0 + vercel@37.14.0: dependencies: "@vercel/build-utils": 8.4.12 @@ -12883,6 +15122,10 @@ snapshots: "@types/unist": 3.0.3 vfile-message: 4.0.3 + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + wavesurfer.js@7.10.1: {} web-vitals@0.2.4: {} @@ -12967,6 +15210,11 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + ws@8.17.1: {} xdg-app-paths@5.1.0: @@ -12979,14 +15227,30 @@ snapshots: xmlhttprequest-ssl@2.0.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} yallist@4.0.0: {} + yaml-ast-parser@0.0.43: {} + yaml@1.10.2: {} yaml@2.8.1: {} + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yauzl-clone@1.0.4: dependencies: events-intercept: 2.0.0 @@ -13005,4 +15269,6 @@ snapshots: yocto-queue@0.1.0: {} + zod@4.1.5: {} + zwitch@2.0.4: {} diff --git a/www/public/service-worker.js b/www/public/service-worker.js index 109561d5..e798e369 100644 --- a/www/public/service-worker.js +++ b/www/public/service-worker.js @@ -1,4 +1,4 @@ -let authToken = ""; // Variable to store the token +let authToken = null; self.addEventListener("message", (event) => { if (event.data && event.data.type === "SET_AUTH_TOKEN") { From 08d88ec349f38b0d13e0fa4cb73486c8dfd31836 Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Fri, 5 Sep 2025 18:39:32 -0400 Subject: [PATCH 04/20] fix: kv use tls explicit (#610) Co-authored-by: Igor Loskutov --- www/app/lib/redisClient.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/www/app/lib/redisClient.ts b/www/app/lib/redisClient.ts index 1be36538..753a0561 100644 --- a/www/app/lib/redisClient.ts +++ b/www/app/lib/redisClient.ts @@ -3,6 +3,10 @@ import { isBuildPhase } from "./next"; export type RedisClient = Pick; +const KV_USE_TLS = process.env.KV_USE_TLS + ? process.env.KV_USE_TLS === "true" + : undefined; + const getRedisClient = (): RedisClient => { const redisUrl = process.env.KV_URL; if (!redisUrl) { @@ -11,6 +15,11 @@ const getRedisClient = (): RedisClient => { const redis = new Redis(redisUrl, { maxRetriesPerRequest: 3, lazyConnect: true, + ...(KV_USE_TLS === true + ? { + tls: {}, + } + : {}), }); redis.on("error", (error) => { From 7f5a4c9ddc7fd098860c8bdda2ca3b57f63ded2f Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Fri, 5 Sep 2025 23:03:24 -0400 Subject: [PATCH 05/20] fix: token refresh locking (#613) * fix: kv use tls explicit * fix: token refresh locking * remove logs * compile fix * compile fix --------- Co-authored-by: Igor Loskutov --- www/app/lib/AuthProvider.tsx | 8 ++- www/app/lib/SessionAutoRefresh.tsx | 22 +++---- www/app/lib/authBackend.ts | 101 +++++++++++++++++++---------- www/app/lib/redisClient.ts | 47 ++++++++++---- www/app/lib/redisTokenCache.ts | 6 +- www/app/lib/useUserName.ts | 3 +- www/app/lib/utils.ts | 11 ++++ www/package.json | 1 + www/pnpm-lock.yaml | 22 +++++++ 9 files changed, 159 insertions(+), 62 deletions(-) diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx index 96f49f87..6c09926b 100644 --- a/www/app/lib/AuthProvider.tsx +++ b/www/app/lib/AuthProvider.tsx @@ -8,10 +8,11 @@ import { assertCustomSession, CustomSession } from "./types"; import { Session } from "next-auth"; import { SessionAutoRefresh } from "./SessionAutoRefresh"; import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth"; +import { assertExists } from "./utils"; type AuthContextType = ( | { status: "loading" } - | { status: "refreshing" } + | { status: "refreshing"; user: CustomSession["user"] } | { status: "unauthenticated"; error?: string } | { status: "authenticated"; @@ -41,7 +42,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { return { status }; } case true: { - return { status: "refreshing" as const }; + return { + status: "refreshing" as const, + user: assertExists(customSession).user, + }; } default: { const _: never = sessionIsHere; diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx index fd29367f..3729db8c 100644 --- a/www/app/lib/SessionAutoRefresh.tsx +++ b/www/app/lib/SessionAutoRefresh.tsx @@ -15,6 +15,7 @@ const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE; export function SessionAutoRefresh({ children }) { const auth = useAuth(); + const accessTokenExpires = auth.status === "authenticated" ? auth.accessTokenExpires : null; @@ -23,17 +24,16 @@ export function SessionAutoRefresh({ children }) { // and not too slow (debuggable) const INTERVAL_REFRESH_MS = 5000; const interval = setInterval(() => { - if (accessTokenExpires !== null) { - const timeLeft = accessTokenExpires - Date.now(); - if (timeLeft < REFRESH_BEFORE) { - auth - .update() - .then(() => {}) - .catch((e) => { - // note: 401 won't be considered error here - console.error("error refreshing auth token", e); - }); - } + if (accessTokenExpires === null) return; + const timeLeft = accessTokenExpires - Date.now(); + if (timeLeft < REFRESH_BEFORE) { + auth + .update() + .then(() => {}) + .catch((e) => { + // note: 401 won't be considered error here + console.error("error refreshing auth token", e); + }); } }, INTERVAL_REFRESH_MS); diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts index af93b274..0b48f613 100644 --- a/www/app/lib/authBackend.ts +++ b/www/app/lib/authBackend.ts @@ -2,7 +2,11 @@ import { AuthOptions } from "next-auth"; import AuthentikProvider from "next-auth/providers/authentik"; import type { JWT } from "next-auth/jwt"; import { JWTWithAccessToken, CustomSession } from "./types"; -import { assertExists, assertExistsAndNonEmptyString } from "./utils"; +import { + assertExists, + assertExistsAndNonEmptyString, + assertNotExists, +} from "./utils"; import { REFRESH_ACCESS_TOKEN_BEFORE, REFRESH_ACCESS_TOKEN_ERROR, @@ -12,14 +16,10 @@ import { setTokenCache, deleteTokenCache, } from "./redisTokenCache"; -import { tokenCacheRedis } from "./redisClient"; +import { tokenCacheRedis, redlock } from "./redisClient"; import { isBuildPhase } from "./next"; -// REFRESH_ACCESS_TOKEN_BEFORE because refresh is based on access token expiration (imagine we cache it 30 days) const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; - -const refreshLocks = new Map>(); - const CLIENT_ID = !isBuildPhase ? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID) : "noop"; @@ -45,31 +45,48 @@ export const authOptions: AuthOptions = { }, callbacks: { async jwt({ token, account, user }) { - const KEY = `token:${token.sub}`; + if (account && !account.access_token) { + await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); + } if (account && user) { // called only on first login // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is - const expiresAtS = assertExists(account.expires_at); - const expiresAtMs = expiresAtS * 1000; - if (!account.access_token) { - await deleteTokenCache(tokenCacheRedis, KEY); - } else { + if (account.access_token) { + const expiresAtS = assertExists(account.expires_at); + const expiresAtMs = expiresAtS * 1000; const jwtToken: JWTWithAccessToken = { ...token, accessToken: account.access_token, accessTokenExpires: expiresAtMs, refreshToken: account.refresh_token, }; - await setTokenCache(tokenCacheRedis, KEY, { - token: jwtToken, - timestamp: Date.now(), - }); - return jwtToken; + if (jwtToken.error) { + await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); + } else { + assertNotExists( + jwtToken.error, + `panic! trying to cache token with error in jwt: ${jwtToken.error}`, + ); + await setTokenCache(tokenCacheRedis, `token:${token.sub}`, { + token: jwtToken, + timestamp: Date.now(), + }); + return jwtToken; + } } } - const currentToken = await getTokenCache(tokenCacheRedis, KEY); + const currentToken = await getTokenCache( + tokenCacheRedis, + `token:${token.sub}`, + ); + console.debug( + "currentToken from cache", + JSON.stringify(currentToken, null, 2), + "will be returned?", + currentToken && Date.now() < currentToken.token.accessTokenExpires, + ); if (currentToken && Date.now() < currentToken.token.accessTokenExpires) { return currentToken.token; } @@ -97,20 +114,22 @@ export const authOptions: AuthOptions = { async function lockedRefreshAccessToken( token: JWT, ): Promise { - const lockKey = `${token.sub}-refresh`; + const lockKey = `${token.sub}-lock`; - const existingRefresh = refreshLocks.get(lockKey); - if (existingRefresh) { - return await existingRefresh; - } - - const refreshPromise = (async () => { - try { + return redlock + .using([lockKey], 10000, async () => { const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`); + if (cached) + console.debug( + "received cached token. to delete?", + Date.now() - cached.timestamp > TOKEN_CACHE_TTL, + ); + else console.debug("no cached token received"); if (cached) { if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) { await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); } else if (Date.now() < cached.token.accessTokenExpires) { + console.debug("returning cached token", cached.token); return cached.token; } } @@ -118,19 +137,35 @@ async function lockedRefreshAccessToken( const currentToken = cached?.token || (token as JWTWithAccessToken); const newToken = await refreshAccessToken(currentToken); + console.debug("current token during refresh", currentToken); + console.debug("new token during refresh", newToken); + + if (newToken.error) { + await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); + return newToken; + } + + assertNotExists( + newToken.error, + `panic! trying to cache token with error during refresh: ${newToken.error}`, + ); await setTokenCache(tokenCacheRedis, `token:${token.sub}`, { token: newToken, timestamp: Date.now(), }); return newToken; - } finally { - setTimeout(() => refreshLocks.delete(lockKey), 100); - } - })(); - - refreshLocks.set(lockKey, refreshPromise); - return refreshPromise; + }) + .catch((e) => { + console.error("error refreshing token", e); + deleteTokenCache(tokenCacheRedis, `token:${token.sub}`).catch((e) => { + console.error("error deleting errored token", e); + }); + return { + ...token, + error: REFRESH_ACCESS_TOKEN_ERROR, + } as JWTWithAccessToken; + }); } async function refreshAccessToken(token: JWT): Promise { diff --git a/www/app/lib/redisClient.ts b/www/app/lib/redisClient.ts index 753a0561..aeb3595b 100644 --- a/www/app/lib/redisClient.ts +++ b/www/app/lib/redisClient.ts @@ -1,20 +1,29 @@ import Redis from "ioredis"; import { isBuildPhase } from "./next"; +import Redlock, { ResourceLockedError } from "redlock"; export type RedisClient = Pick; - +export type RedlockClient = { + using: ( + keys: string | string[], + ttl: number, + cb: () => Promise, + ) => Promise; +}; const KV_USE_TLS = process.env.KV_USE_TLS ? process.env.KV_USE_TLS === "true" : undefined; +let redisClient: Redis | null = null; + const getRedisClient = (): RedisClient => { + if (redisClient) return redisClient; const redisUrl = process.env.KV_URL; if (!redisUrl) { throw new Error("KV_URL environment variable is required"); } - const redis = new Redis(redisUrl, { + redisClient = new Redis(redisUrl, { maxRetriesPerRequest: 3, - lazyConnect: true, ...(KV_USE_TLS === true ? { tls: {}, @@ -22,18 +31,11 @@ const getRedisClient = (): RedisClient => { : {}), }); - redis.on("error", (error) => { + redisClient.on("error", (error) => { console.error("Redis error:", error); }); - // not necessary but will indicate redis config errors by failfast at startup - // happens only once; after that connection is allowed to die and the lib is assumed to be able to restore it eventually - redis.connect().catch((e) => { - console.error("Failed to connect to Redis:", e); - process.exit(1); - }); - - return redis; + return redisClient; }; // next.js buildtime usage - we want to isolate next.js "build" time concepts here @@ -52,4 +54,25 @@ const noopClient: RedisClient = (() => { del: noopDel, }; })(); + +const noopRedlock: RedlockClient = { + using: (resource: string | string[], ttl: number, cb: () => Promise) => + cb(), +}; + +export const redlock: RedlockClient = isBuildPhase + ? noopRedlock + : (() => { + const r = new Redlock([getRedisClient()], {}); + r.on("error", (error) => { + if (error instanceof ResourceLockedError) { + return; + } + + // Log all other errors. + console.error(error); + }); + return r; + })(); + export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient(); diff --git a/www/app/lib/redisTokenCache.ts b/www/app/lib/redisTokenCache.ts index 4fa4e304..a8b720ef 100644 --- a/www/app/lib/redisTokenCache.ts +++ b/www/app/lib/redisTokenCache.ts @@ -9,7 +9,6 @@ const TokenCacheEntrySchema = z.object({ accessToken: z.string(), accessTokenExpires: z.number(), refreshToken: z.string().optional(), - error: z.string().optional(), }), timestamp: z.number(), }); @@ -46,14 +45,15 @@ export async function getTokenCache( } } +const TTL_SECONDS = 30 * 24 * 60 * 60; + export async function setTokenCache( redis: KV, key: string, value: TokenCacheEntry, ): Promise { const encodedValue = TokenCacheEntryCodec.encode(value); - const ttlSeconds = Math.floor(REFRESH_ACCESS_TOKEN_BEFORE / 1000); - await redis.setex(key, ttlSeconds, encodedValue); + await redis.setex(key, TTL_SECONDS, encodedValue); } export async function deleteTokenCache(redis: KV, key: string): Promise { diff --git a/www/app/lib/useUserName.ts b/www/app/lib/useUserName.ts index 80814281..46850176 100644 --- a/www/app/lib/useUserName.ts +++ b/www/app/lib/useUserName.ts @@ -2,6 +2,7 @@ import { useAuth } from "./AuthProvider"; export const useUserName = (): string | null | undefined => { const auth = useAuth(); - if (auth.status !== "authenticated") return undefined; + if (auth.status !== "authenticated" && auth.status !== "refreshing") + return undefined; return auth.user?.name || null; }; diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts index 122ab234..8e8651ff 100644 --- a/www/app/lib/utils.ts +++ b/www/app/lib/utils.ts @@ -158,6 +158,17 @@ export const assertExists = ( return value; }; +export const assertNotExists = ( + value: T | null | undefined, + err?: string, +): void => { + if (value !== null && value !== undefined) { + throw new Error( + `Assertion failed: ${err ?? "value is not null or undefined"}`, + ); + } +}; + export const assertExistsAndNonEmptyString = ( value: string | null | undefined, ): NonEmptyString => diff --git a/www/package.json b/www/package.json index b7511147..27e30a5f 100644 --- a/www/package.json +++ b/www/package.json @@ -45,6 +45,7 @@ "react-markdown": "^9.0.0", "react-qr-code": "^2.0.12", "react-select-search": "^4.1.7", + "redlock": "5.0.0-beta.2", "sass": "^1.63.6", "simple-peer": "^9.11.1", "tailwindcss": "^3.3.2", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index 14b42c55..f4346855 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: react-select-search: specifier: ^4.1.7 version: 4.1.8(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + redlock: + specifier: 5.0.0-beta.2 + version: 5.0.0-beta.2 sass: specifier: ^1.63.6 version: 1.90.0 @@ -6566,6 +6569,12 @@ packages: sass: optional: true + node-abort-controller@3.1.1: + resolution: + { + integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==, + } + node-addon-api@7.1.1: resolution: { @@ -7433,6 +7442,13 @@ packages: } engines: { node: ">=4" } + redlock@5.0.0-beta.2: + resolution: + { + integrity: sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw==, + } + engines: { node: ">=12" } + redux-thunk@3.1.0: resolution: { @@ -13812,6 +13828,8 @@ snapshots: - "@babel/core" - babel-plugin-macros + node-abort-controller@3.1.1: {} + node-addon-api@7.1.1: optional: true @@ -14290,6 +14308,10 @@ snapshots: dependencies: redis-errors: 1.2.0 + redlock@5.0.0-beta.2: + dependencies: + node-abort-controller: 3.1.1 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 From 02a3938822f7167125bc8a3a250eda6ea850f273 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Fri, 5 Sep 2025 22:50:10 -0600 Subject: [PATCH 06/20] chore(main): release 0.9.0 (#603) --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 433691e9..987a6579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06) + + +### Features + +* frontend openapi react query ([#606](https://github.com/Monadical-SAS/reflector/issues/606)) ([c4d2825](https://github.com/Monadical-SAS/reflector/commit/c4d2825c81f81ad8835629fbf6ea8c7383f8c31b)) + + +### Bug Fixes + +* align whisper transcriber api with parakeet ([#602](https://github.com/Monadical-SAS/reflector/issues/602)) ([0663700](https://github.com/Monadical-SAS/reflector/commit/0663700a615a4af69a03c96c410f049e23ec9443)) +* kv use tls explicit ([#610](https://github.com/Monadical-SAS/reflector/issues/610)) ([08d88ec](https://github.com/Monadical-SAS/reflector/commit/08d88ec349f38b0d13e0fa4cb73486c8dfd31836)) +* source kind for file processing ([#601](https://github.com/Monadical-SAS/reflector/issues/601)) ([dc82f8b](https://github.com/Monadical-SAS/reflector/commit/dc82f8bb3bdf3ab3d4088e592a30fd63907319e1)) +* token refresh locking ([#613](https://github.com/Monadical-SAS/reflector/issues/613)) ([7f5a4c9](https://github.com/Monadical-SAS/reflector/commit/7f5a4c9ddc7fd098860c8bdda2ca3b57f63ded2f)) + ## [0.8.2](https://github.com/Monadical-SAS/reflector/compare/v0.8.1...v0.8.2) (2025-08-29) From 5a5b3233820df9536da75e87ce6184a983d4713a Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Mon, 8 Sep 2025 10:40:18 -0400 Subject: [PATCH 07/20] fix: sync backend and frontend token refresh logic (#614) * sync backend and frontend token refresh logic * return react strict mode --------- Co-authored-by: Igor Loskutov --- www/app/lib/SessionAutoRefresh.tsx | 7 ++----- www/app/lib/auth.ts | 5 +++++ www/app/lib/authBackend.ts | 11 ++++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx index 3729db8c..6b26077d 100644 --- a/www/app/lib/SessionAutoRefresh.tsx +++ b/www/app/lib/SessionAutoRefresh.tsx @@ -9,9 +9,7 @@ import { useEffect } from "react"; import { useAuth } from "./AuthProvider"; -import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth"; - -const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE; +import { shouldRefreshToken } from "./auth"; export function SessionAutoRefresh({ children }) { const auth = useAuth(); @@ -25,8 +23,7 @@ export function SessionAutoRefresh({ children }) { const INTERVAL_REFRESH_MS = 5000; const interval = setInterval(() => { if (accessTokenExpires === null) return; - const timeLeft = accessTokenExpires - Date.now(); - if (timeLeft < REFRESH_BEFORE) { + if (shouldRefreshToken(accessTokenExpires)) { auth .update() .then(() => {}) diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts index f6e60513..c83db264 100644 --- a/www/app/lib/auth.ts +++ b/www/app/lib/auth.ts @@ -2,6 +2,11 @@ export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const; // 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000; +export const shouldRefreshToken = (accessTokenExpires: number): boolean => { + const timeLeft = accessTokenExpires - Date.now(); + return timeLeft < REFRESH_ACCESS_TOKEN_BEFORE; +}; + export const LOGIN_REQUIRED_PAGES = [ "/transcripts/[!new]", "/browse(.*)", diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts index 0b48f613..06bddff2 100644 --- a/www/app/lib/authBackend.ts +++ b/www/app/lib/authBackend.ts @@ -10,6 +10,7 @@ import { import { REFRESH_ACCESS_TOKEN_BEFORE, REFRESH_ACCESS_TOKEN_ERROR, + shouldRefreshToken, } from "./auth"; import { getTokenCache, @@ -85,9 +86,13 @@ export const authOptions: AuthOptions = { "currentToken from cache", JSON.stringify(currentToken, null, 2), "will be returned?", - currentToken && Date.now() < currentToken.token.accessTokenExpires, + currentToken && + !shouldRefreshToken(currentToken.token.accessTokenExpires), ); - if (currentToken && Date.now() < currentToken.token.accessTokenExpires) { + if ( + currentToken && + !shouldRefreshToken(currentToken.token.accessTokenExpires) + ) { return currentToken.token; } @@ -128,7 +133,7 @@ async function lockedRefreshAccessToken( if (cached) { if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) { await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); - } else if (Date.now() < cached.token.accessTokenExpires) { + } else if (!shouldRefreshToken(cached.token.accessTokenExpires)) { console.debug("returning cached token", cached.token); return cached.token; } From f81fe9948a9237b3e0001b2d8ca84f54d76878f9 Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Tue, 9 Sep 2025 10:50:29 -0400 Subject: [PATCH 08/20] fix: anonymous users transcript permissions (#621) * fix: public transcript visibility * fix: transcript permissions frontend * dead code removal * chore: remove unused code * fix search tests * fix search tests --------- Co-authored-by: Igor Loskutov --- server/reflector/db/search.py | 4 +- server/reflector/views/rooms.py | 12 ++-- server/reflector/views/transcripts.py | 2 - server/tests/test_search.py | 16 ++--- server/tests/test_search_long_summary.py | 4 +- .../_components/TranscriptStatusIcon.tsx | 3 +- .../[transcriptId]/_components/TopicList.tsx | 3 +- .../[transcriptId]/correct/page.tsx | 12 ++-- .../(app)/transcripts/[transcriptId]/page.tsx | 35 ++++++---- .../[transcriptId]/record/page.tsx | 13 ++-- .../[transcriptId]/upload/page.tsx | 16 ++--- www/app/(app)/transcripts/recorder.tsx | 3 +- www/app/(app)/transcripts/useTranscript.ts | 69 ------------------- www/app/(app)/transcripts/useWebSockets.ts | 4 +- www/app/(app)/transcripts/webSocketTypes.ts | 3 +- www/app/lib/apiHooks.ts | 16 ++--- www/app/lib/transcript.ts | 5 ++ www/app/reflector-api.d.ts | 13 +++- 18 files changed, 90 insertions(+), 143 deletions(-) delete mode 100644 www/app/(app)/transcripts/useTranscript.ts create mode 100644 www/app/lib/transcript.ts diff --git a/server/reflector/db/search.py b/server/reflector/db/search.py index 66a25ccf..caa21c65 100644 --- a/server/reflector/db/search.py +++ b/server/reflector/db/search.py @@ -23,7 +23,7 @@ from pydantic import ( from reflector.db import get_database from reflector.db.rooms import rooms -from reflector.db.transcripts import SourceKind, transcripts +from reflector.db.transcripts import SourceKind, TranscriptStatus, transcripts from reflector.db.utils import is_postgresql from reflector.logger import logger from reflector.utils.string import NonEmptyString, try_parse_non_empty_string @@ -161,7 +161,7 @@ class SearchResult(BaseModel): room_name: str | None = None source_kind: SourceKind created_at: datetime - status: str = Field(..., min_length=1) + status: TranscriptStatus = Field(..., min_length=1) rank: float = Field(..., ge=0, le=1) duration: NonNegativeFloat | None = Field(..., description="Duration in seconds") search_snippets: list[str] = Field( diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index cc00f3c0..38b611d6 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -215,14 +215,10 @@ async def rooms_create_meeting( except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError): # Another request already created a meeting for this room # Log this race condition occurrence - logger.info( - "Race condition detected for room %s - fetching existing meeting", - room.name, - ) logger.warning( - "Whereby meeting %s was created but not used (resource leak) for room %s", - whereby_meeting["meetingId"], + "Race condition detected for room %s and meeting %s - fetching existing meeting", room.name, + whereby_meeting["meetingId"], ) # Fetch the meeting that was created by the other request @@ -232,7 +228,9 @@ async def rooms_create_meeting( if meeting is None: # Edge case: meeting was created but expired/deleted between checks logger.error( - "Meeting disappeared after race condition for room %s", room.name + "Meeting disappeared after race condition for room %s", + room.name, + exc_info=True, ) raise HTTPException( status_code=503, detail="Unable to join meeting - please try again" diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 9acfcbf8..ed2445ae 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -350,8 +350,6 @@ async def transcript_update( transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) - if not transcript: - raise HTTPException(status_code=404, detail="Transcript not found") values = info.dict(exclude_unset=True) updated_transcript = await transcripts_controller.update(transcript, values) return updated_transcript diff --git a/server/tests/test_search.py b/server/tests/test_search.py index 0f5c8923..82890080 100644 --- a/server/tests/test_search.py +++ b/server/tests/test_search.py @@ -58,7 +58,7 @@ async def test_empty_transcript_title_only_match(): "id": test_id, "name": "Empty Transcript", "title": "Empty Meeting", - "status": "completed", + "status": "ended", "locked": False, "duration": 0.0, "created_at": datetime.now(timezone.utc), @@ -109,7 +109,7 @@ async def test_search_with_long_summary(): "id": test_id, "name": "Test Long Summary", "title": "Regular Meeting", - "status": "completed", + "status": "ended", "locked": False, "duration": 1800.0, "created_at": datetime.now(timezone.utc), @@ -165,7 +165,7 @@ async def test_postgresql_search_with_data(): "id": test_id, "name": "Test Search Transcript", "title": "Engineering Planning Meeting Q4 2024", - "status": "completed", + "status": "ended", "locked": False, "duration": 1800.0, "created_at": datetime.now(timezone.utc), @@ -221,7 +221,7 @@ We need to implement PostgreSQL tsvector for better performance.""", test_result = next((r for r in results if r.id == test_id), None) if test_result: assert test_result.title == "Engineering Planning Meeting Q4 2024" - assert test_result.status == "completed" + assert test_result.status == "ended" assert test_result.duration == 1800.0 assert 0 <= test_result.rank <= 1, "Rank should be normalized to 0-1" @@ -268,7 +268,7 @@ def mock_db_result(): "title": "Test Transcript", "created_at": datetime(2024, 6, 15, tzinfo=timezone.utc), "duration": 3600.0, - "status": "completed", + "status": "ended", "user_id": "test-user", "room_id": "room1", "source_kind": SourceKind.LIVE, @@ -433,7 +433,7 @@ class TestSearchResultModel: room_id="room-456", source_kind=SourceKind.ROOM, created_at=datetime(2024, 6, 15, tzinfo=timezone.utc), - status="completed", + status="ended", rank=0.85, duration=1800.5, search_snippets=["snippet 1", "snippet 2"], @@ -443,7 +443,7 @@ class TestSearchResultModel: assert result.title == "Test Title" assert result.user_id == "user-123" assert result.room_id == "room-456" - assert result.status == "completed" + assert result.status == "ended" assert result.rank == 0.85 assert result.duration == 1800.5 assert len(result.search_snippets) == 2 @@ -474,7 +474,7 @@ class TestSearchResultModel: id="test-id", source_kind=SourceKind.LIVE, created_at=datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc), - status="completed", + status="ended", rank=0.9, duration=None, search_snippets=[], diff --git a/server/tests/test_search_long_summary.py b/server/tests/test_search_long_summary.py index 8857778b..3f911a99 100644 --- a/server/tests/test_search_long_summary.py +++ b/server/tests/test_search_long_summary.py @@ -25,7 +25,7 @@ async def test_long_summary_snippet_prioritization(): "id": test_id, "name": "Test Snippet Priority", "title": "Meeting About Projects", - "status": "completed", + "status": "ended", "locked": False, "duration": 1800.0, "created_at": datetime.now(timezone.utc), @@ -106,7 +106,7 @@ async def test_long_summary_only_search(): "id": test_id, "name": "Test Long Only", "title": "Standard Meeting", - "status": "completed", + "status": "ended", "locked": False, "duration": 1800.0, "created_at": datetime.now(timezone.utc), diff --git a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx index 0eebadc8..20164993 100644 --- a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx +++ b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx @@ -7,9 +7,10 @@ import { FaMicrophone, FaGear, } from "react-icons/fa6"; +import { TranscriptStatus } from "../../../lib/transcript"; interface TranscriptStatusIconProps { - status: string; + status: TranscriptStatus; } export default function TranscriptStatusIcon({ diff --git a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx index 1f5d1588..534f0c0a 100644 --- a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx @@ -5,6 +5,7 @@ import useParticipants from "../../useParticipants"; import { Box, Flex, Text, Accordion } from "@chakra-ui/react"; import { featureEnabled } from "../../../../domainContext"; import { TopicItem } from "./TopicItem"; +import { TranscriptStatus } from "../../../../lib/transcript"; type TopicListProps = { topics: Topic[]; @@ -14,7 +15,7 @@ type TopicListProps = { ]; autoscroll: boolean; transcriptId: string; - status: string; + status: TranscriptStatus | null; currentTranscriptText: any; }; diff --git a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx index c885ca6e..1c7705f4 100644 --- a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx @@ -9,8 +9,10 @@ import ParticipantList from "./participantList"; import type { components } from "../../../../reflector-api"; type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; import { SelectedText, selectedTextIsTimeSlice } from "./types"; -import { useTranscriptUpdate } from "../../../../lib/apiHooks"; -import useTranscript from "../../useTranscript"; +import { + useTranscriptGet, + useTranscriptUpdate, +} from "../../../../lib/apiHooks"; import { useError } from "../../../../(errors)/errorContext"; import { useRouter } from "next/navigation"; import { Box, Grid } from "@chakra-ui/react"; @@ -25,7 +27,7 @@ export default function TranscriptCorrect({ params: { transcriptId }, }: TranscriptCorrect) { const updateTranscriptMutation = useTranscriptUpdate(); - const transcript = useTranscript(transcriptId); + const transcript = useTranscriptGet(transcriptId); const stateCurrentTopic = useState(); const [currentTopic, _sct] = stateCurrentTopic; const stateSelectedText = useState(); @@ -36,7 +38,7 @@ export default function TranscriptCorrect({ const router = useRouter(); const markAsDone = async () => { - if (transcript.response && !transcript.response.reviewed) { + if (transcript.data && !transcript.data.reviewed) { try { await updateTranscriptMutation.mutateAsync({ params: { @@ -114,7 +116,7 @@ export default function TranscriptCorrect({ }} /> - {transcript.response && !transcript.response?.reviewed && ( + {transcript.data && !transcript.data?.reviewed && (