Merge branch 'main' into mathieu/calendar-integration-rebased2

This commit is contained in:
2025-09-10 18:52:41 -06:00
31 changed files with 170 additions and 218 deletions

View File

@@ -2,7 +2,6 @@ from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
import sqlalchemy as sa import sqlalchemy as sa
from fastapi import HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
@@ -233,23 +232,6 @@ class MeetingController:
return None return None
return Meeting(**result) return Meeting(**result)
async def get_by_id_for_http(self, meeting_id: str, user_id: str | None) -> Meeting:
"""
Get a meeting by ID for HTTP request.
If not found, it will raise a 404 error.
"""
query = meetings.select().where(meetings.c.id == meeting_id)
result = await get_database().fetch_one(query)
if not result:
raise HTTPException(status_code=404, detail="Meeting not found")
meeting = Meeting(**result)
if result["user_id"] != user_id:
meeting.host_room_url = ""
return meeting
async def get_by_calendar_event(self, calendar_event_id: str) -> Meeting | None: async def get_by_calendar_event(self, calendar_event_id: str) -> Meeting | None:
query = meetings.select().where( query = meetings.select().where(
meetings.c.calendar_event_id == calendar_event_id meetings.c.calendar_event_id == calendar_event_id

View File

@@ -23,7 +23,7 @@ from pydantic import (
from reflector.db import get_database from reflector.db import get_database
from reflector.db.rooms import rooms 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.db.utils import is_postgresql
from reflector.logger import logger from reflector.logger import logger
from reflector.utils.string import NonEmptyString, try_parse_non_empty_string from reflector.utils.string import NonEmptyString, try_parse_non_empty_string
@@ -161,7 +161,7 @@ class SearchResult(BaseModel):
room_name: str | None = None room_name: str | None = None
source_kind: SourceKind source_kind: SourceKind
created_at: datetime created_at: datetime
status: str = Field(..., min_length=1) status: TranscriptStatus = Field(..., min_length=1)
rank: float = Field(..., ge=0, le=1) rank: float = Field(..., ge=0, le=1)
duration: NonNegativeFloat | None = Field(..., description="Duration in seconds") duration: NonNegativeFloat | None = Field(..., description="Duration in seconds")
search_snippets: list[str] = Field( search_snippets: list[str] = Field(

View File

@@ -47,6 +47,7 @@ class FileDiarizationModalProcessor(FileDiarizationProcessor):
"audio_file_url": data.audio_url, "audio_file_url": data.audio_url,
"timestamp": 0, "timestamp": 0,
}, },
follow_redirects=True,
) )
response.raise_for_status() response.raise_for_status()
diarization_data = response.json()["diarization"] diarization_data = response.json()["diarization"]

View File

@@ -54,6 +54,7 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
"language": data.language, "language": data.language,
"batch": True, "batch": True,
}, },
follow_redirects=True,
) )
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()

View File

@@ -244,14 +244,10 @@ async def rooms_create_meeting(
except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError): except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError):
# Another request already created a meeting for this room # Another request already created a meeting for this room
# Log this race condition occurrence # Log this race condition occurrence
logger.info(
"Race condition detected for room %s - fetching existing meeting",
room.name,
)
logger.warning( logger.warning(
"Whereby meeting %s was created but not used (resource leak) for room %s", "Race condition detected for room %s and meeting %s - fetching existing meeting",
whereby_meeting["meetingId"],
room.name, room.name,
whereby_meeting["meetingId"],
) )
# Fetch the meeting that was created by the other request # Fetch the meeting that was created by the other request
@@ -261,7 +257,9 @@ async def rooms_create_meeting(
if meeting is None: if meeting is None:
# Edge case: meeting was created but expired/deleted between checks # Edge case: meeting was created but expired/deleted between checks
logger.error( 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( raise HTTPException(
status_code=503, detail="Unable to join meeting - please try again" status_code=503, detail="Unable to join meeting - please try again"

View File

@@ -350,8 +350,6 @@ async def transcript_update(
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
values = info.dict(exclude_unset=True) values = info.dict(exclude_unset=True)
updated_transcript = await transcripts_controller.update(transcript, values) updated_transcript = await transcripts_controller.update(transcript, values)
return updated_transcript return updated_transcript

View File

@@ -42,9 +42,9 @@ else:
"task": "reflector.worker.ics_sync.sync_all_ics_calendars", "task": "reflector.worker.ics_sync.sync_all_ics_calendars",
"schedule": 60.0, # Run every minute to check which rooms need sync "schedule": 60.0, # Run every minute to check which rooms need sync
}, },
"pre_create_upcoming_meetings": { "create_upcoming_meetings": {
"task": "reflector.worker.ics_sync.pre_create_upcoming_meetings", "task": "reflector.worker.ics_sync.create_upcoming_meetings",
"schedule": 30.0, # Run every 30 seconds to pre-create meetings "schedule": 30.0, # Run every 30 seconds to create upcoming meetings
}, },
} }

View File

@@ -179,7 +179,9 @@ async def create_upcoming_meetings():
) )
for event in events: for event in events:
await create_upcoming_meetings_for_event(event) await create_upcoming_meetings_for_event(
event, create_window, room_id, room
)
logger.info("Completed pre-creation check for upcoming meetings") logger.info("Completed pre-creation check for upcoming meetings")
except Exception as e: except Exception as e:

View File

@@ -8,8 +8,8 @@ from reflector.db.calendar_events import calendar_events_controller
from reflector.db.rooms import rooms_controller from reflector.db.rooms import rooms_controller
from reflector.worker.ics_sync import ( from reflector.worker.ics_sync import (
_should_sync, _should_sync,
_sync_all_ics_calendars_async, sync_all_ics_calendars,
_sync_room_ics_async, sync_room_ics,
) )
@@ -48,7 +48,7 @@ async def test_sync_room_ics_task():
) as mock_fetch: ) as mock_fetch:
mock_fetch.return_value = ics_content mock_fetch.return_value = ics_content
await _sync_room_ics_async(room.id) await sync_room_ics(room.id)
events = await calendar_events_controller.get_by_room(room.id) events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 1 assert len(events) == 1
@@ -124,7 +124,7 @@ async def test_sync_all_ics_calendars():
) )
with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay: with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay:
await _sync_all_ics_calendars_async() await sync_all_ics_calendars()
assert mock_delay.call_count == 2 assert mock_delay.call_count == 2
called_room_ids = [call.args[0] for call in mock_delay.call_args_list] called_room_ids = [call.args[0] for call in mock_delay.call_args_list]
@@ -196,7 +196,7 @@ async def test_sync_respects_fetch_interval():
) )
with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay: with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay:
await _sync_all_ics_calendars_async() await sync_all_ics_calendars()
assert mock_delay.call_count == 1 assert mock_delay.call_count == 1
assert mock_delay.call_args[0][0] == room2.id assert mock_delay.call_args[0][0] == room2.id
@@ -224,7 +224,7 @@ async def test_sync_handles_errors_gracefully():
) as mock_fetch: ) as mock_fetch:
mock_fetch.side_effect = Exception("Network error") mock_fetch.side_effect = Exception("Network error")
await _sync_room_ics_async(room.id) await sync_room_ics(room.id)
events = await calendar_events_controller.get_by_room(room.id) events = await calendar_events_controller.get_by_room(room.id)
assert len(events) == 0 assert len(events) == 0

View File

@@ -58,7 +58,7 @@ async def test_empty_transcript_title_only_match():
"id": test_id, "id": test_id,
"name": "Empty Transcript", "name": "Empty Transcript",
"title": "Empty Meeting", "title": "Empty Meeting",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 0.0, "duration": 0.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),
@@ -109,7 +109,7 @@ async def test_search_with_long_summary():
"id": test_id, "id": test_id,
"name": "Test Long Summary", "name": "Test Long Summary",
"title": "Regular Meeting", "title": "Regular Meeting",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 1800.0, "duration": 1800.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),
@@ -165,7 +165,7 @@ async def test_postgresql_search_with_data():
"id": test_id, "id": test_id,
"name": "Test Search Transcript", "name": "Test Search Transcript",
"title": "Engineering Planning Meeting Q4 2024", "title": "Engineering Planning Meeting Q4 2024",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 1800.0, "duration": 1800.0,
"created_at": datetime.now(timezone.utc), "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) test_result = next((r for r in results if r.id == test_id), None)
if test_result: if test_result:
assert test_result.title == "Engineering Planning Meeting Q4 2024" 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 test_result.duration == 1800.0
assert 0 <= test_result.rank <= 1, "Rank should be normalized to 0-1" assert 0 <= test_result.rank <= 1, "Rank should be normalized to 0-1"
@@ -268,7 +268,7 @@ def mock_db_result():
"title": "Test Transcript", "title": "Test Transcript",
"created_at": datetime(2024, 6, 15, tzinfo=timezone.utc), "created_at": datetime(2024, 6, 15, tzinfo=timezone.utc),
"duration": 3600.0, "duration": 3600.0,
"status": "completed", "status": "ended",
"user_id": "test-user", "user_id": "test-user",
"room_id": "room1", "room_id": "room1",
"source_kind": SourceKind.LIVE, "source_kind": SourceKind.LIVE,
@@ -433,7 +433,7 @@ class TestSearchResultModel:
room_id="room-456", room_id="room-456",
source_kind=SourceKind.ROOM, source_kind=SourceKind.ROOM,
created_at=datetime(2024, 6, 15, tzinfo=timezone.utc), created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
status="completed", status="ended",
rank=0.85, rank=0.85,
duration=1800.5, duration=1800.5,
search_snippets=["snippet 1", "snippet 2"], search_snippets=["snippet 1", "snippet 2"],
@@ -443,7 +443,7 @@ class TestSearchResultModel:
assert result.title == "Test Title" assert result.title == "Test Title"
assert result.user_id == "user-123" assert result.user_id == "user-123"
assert result.room_id == "room-456" assert result.room_id == "room-456"
assert result.status == "completed" assert result.status == "ended"
assert result.rank == 0.85 assert result.rank == 0.85
assert result.duration == 1800.5 assert result.duration == 1800.5
assert len(result.search_snippets) == 2 assert len(result.search_snippets) == 2
@@ -474,7 +474,7 @@ class TestSearchResultModel:
id="test-id", id="test-id",
source_kind=SourceKind.LIVE, source_kind=SourceKind.LIVE,
created_at=datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc), created_at=datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc),
status="completed", status="ended",
rank=0.9, rank=0.9,
duration=None, duration=None,
search_snippets=[], search_snippets=[],

View File

@@ -25,7 +25,7 @@ async def test_long_summary_snippet_prioritization():
"id": test_id, "id": test_id,
"name": "Test Snippet Priority", "name": "Test Snippet Priority",
"title": "Meeting About Projects", "title": "Meeting About Projects",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 1800.0, "duration": 1800.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),
@@ -106,7 +106,7 @@ async def test_long_summary_only_search():
"id": test_id, "id": test_id,
"name": "Test Long Only", "name": "Test Long Only",
"title": "Standard Meeting", "title": "Standard Meeting",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 1800.0, "duration": 1800.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),

View File

@@ -7,9 +7,10 @@ import {
FaMicrophone, FaMicrophone,
FaGear, FaGear,
} from "react-icons/fa6"; } from "react-icons/fa6";
import { TranscriptStatus } from "../../../lib/transcript";
interface TranscriptStatusIconProps { interface TranscriptStatusIconProps {
status: string; status: TranscriptStatus;
} }
export default function TranscriptStatusIcon({ export default function TranscriptStatusIcon({

View File

@@ -5,6 +5,7 @@ import useParticipants from "../../useParticipants";
import { Box, Flex, Text, Accordion } from "@chakra-ui/react"; import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
import { featureEnabled } from "../../../../domainContext"; import { featureEnabled } from "../../../../domainContext";
import { TopicItem } from "./TopicItem"; import { TopicItem } from "./TopicItem";
import { TranscriptStatus } from "../../../../lib/transcript";
type TopicListProps = { type TopicListProps = {
topics: Topic[]; topics: Topic[];
@@ -14,7 +15,7 @@ type TopicListProps = {
]; ];
autoscroll: boolean; autoscroll: boolean;
transcriptId: string; transcriptId: string;
status: string; status: TranscriptStatus | null;
currentTranscriptText: any; currentTranscriptText: any;
}; };

View File

@@ -9,8 +9,10 @@ import ParticipantList from "./participantList";
import type { components } from "../../../../reflector-api"; import type { components } from "../../../../reflector-api";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { SelectedText, selectedTextIsTimeSlice } from "./types"; import { SelectedText, selectedTextIsTimeSlice } from "./types";
import { useTranscriptUpdate } from "../../../../lib/apiHooks"; import {
import useTranscript from "../../useTranscript"; useTranscriptGet,
useTranscriptUpdate,
} from "../../../../lib/apiHooks";
import { useError } from "../../../../(errors)/errorContext"; import { useError } from "../../../../(errors)/errorContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react"; import { Box, Grid } from "@chakra-ui/react";
@@ -25,7 +27,7 @@ export default function TranscriptCorrect({
params: { transcriptId }, params: { transcriptId },
}: TranscriptCorrect) { }: TranscriptCorrect) {
const updateTranscriptMutation = useTranscriptUpdate(); const updateTranscriptMutation = useTranscriptUpdate();
const transcript = useTranscript(transcriptId); const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>(); const stateCurrentTopic = useState<GetTranscriptTopic>();
const [currentTopic, _sct] = stateCurrentTopic; const [currentTopic, _sct] = stateCurrentTopic;
const stateSelectedText = useState<SelectedText>(); const stateSelectedText = useState<SelectedText>();
@@ -36,7 +38,7 @@ export default function TranscriptCorrect({
const router = useRouter(); const router = useRouter();
const markAsDone = async () => { const markAsDone = async () => {
if (transcript.response && !transcript.response.reviewed) { if (transcript.data && !transcript.data.reviewed) {
try { try {
await updateTranscriptMutation.mutateAsync({ await updateTranscriptMutation.mutateAsync({
params: { params: {
@@ -114,7 +116,7 @@ export default function TranscriptCorrect({
}} }}
/> />
</Grid> </Grid>
{transcript.response && !transcript.response?.reviewed && ( {transcript.data && !transcript.data?.reviewed && (
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<button <button
className="p-2 px-4 rounded bg-green-400" className="p-2 px-4 rounded bg-green-400"

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import Modal from "../modal"; import Modal from "../modal";
import useTranscript from "../useTranscript";
import useTopics from "../useTopics"; import useTopics from "../useTopics";
import useWaveform from "../useWaveform"; import useWaveform from "../useWaveform";
import useMp3 from "../useMp3"; import useMp3 from "../useMp3";
@@ -12,6 +11,8 @@ import TranscriptTitle from "../transcriptTitle";
import Player from "../player"; import Player from "../player";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react"; import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -22,11 +23,15 @@ type TranscriptDetails = {
export default function TranscriptDetails(details: TranscriptDetails) { export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId; const transcriptId = details.params.transcriptId;
const router = useRouter(); const router = useRouter();
const statusToRedirect = ["idle", "recording", "processing"]; const statusToRedirect = [
"idle",
"recording",
"processing",
] satisfies TranscriptStatus[] as TranscriptStatus[];
const transcript = useTranscript(transcriptId); const transcript = useTranscriptGet(transcriptId);
const transcriptStatus = transcript.response?.status; const waiting =
const waiting = statusToRedirect.includes(transcriptStatus || ""); transcript.data && statusToRedirect.includes(transcript.data.status);
const mp3 = useMp3(transcriptId, waiting); const mp3 = useMp3(transcriptId, waiting);
const topics = useTopics(transcriptId); const topics = useTopics(transcriptId);
@@ -56,7 +61,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
); );
} }
if (transcript?.loading || topics?.loading) { if (transcript?.isLoading || topics?.loading) {
return <Modal title="Loading" text={"Loading transcript..."} />; return <Modal title="Loading" text={"Loading transcript..."} />;
} }
@@ -86,7 +91,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
waveform={waveform.waveform} waveform={waveform.waveform}
media={mp3.media} media={mp3.media}
mediaDuration={transcript.response?.duration || null} mediaDuration={transcript.data?.duration || null}
/> />
) : !mp3.loading && (waveform.error || mp3.error) ? ( ) : !mp3.loading && (waveform.error || mp3.error) ? (
<Box p={4} bg="red.100" borderRadius="md"> <Box p={4} bg="red.100" borderRadius="md">
@@ -116,10 +121,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
<Flex direction="column" gap={0}> <Flex direction="column" gap={0}>
<Flex alignItems="center" gap={2}> <Flex alignItems="center" gap={2}>
<TranscriptTitle <TranscriptTitle
title={transcript.response?.title || "Unnamed Transcript"} title={transcript.data?.title || "Unnamed Transcript"}
transcriptId={transcriptId} transcriptId={transcriptId}
onUpdate={(newTitle) => { onUpdate={(newTitle) => {
transcript.reload(); transcript.refetch().then(() => {});
}} }}
/> />
</Flex> </Flex>
@@ -136,23 +141,23 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
autoscroll={false} autoscroll={false}
transcriptId={transcriptId} transcriptId={transcriptId}
status={transcript.response?.status} status={transcript.data?.status || null}
currentTranscriptText="" currentTranscriptText=""
/> />
{transcript.response && topics.topics ? ( {transcript.data && topics.topics ? (
<> <>
<FinalSummary <FinalSummary
transcriptResponse={transcript.response} transcriptResponse={transcript.data}
topicsResponse={topics.topics} topicsResponse={topics.topics}
onUpdate={(newSummary) => { onUpdate={() => {
transcript.reload(); transcript.refetch();
}} }}
/> />
</> </>
) : ( ) : (
<Flex justify={"center"} alignItems={"center"} h={"100%"}> <Flex justify={"center"} alignItems={"center"} h={"100%"}>
<div className="flex flex-col h-full justify-center content-center"> <div className="flex flex-col h-full justify-center content-center">
{transcript.response.status == "processing" ? ( {transcript?.data?.status == "processing" ? (
<Text>Loading Transcript</Text> <Text>Loading Transcript</Text>
) : ( ) : (
<Text> <Text>

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Recorder from "../../recorder"; import Recorder from "../../recorder";
import { TopicList } from "../_components/TopicList"; import { TopicList } from "../_components/TopicList";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
import { Topic } from "../../webSocketTypes"; import { Topic } from "../../webSocketTypes";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
@@ -11,6 +10,8 @@ import useMp3 from "../../useMp3";
import WaveformLoading from "../../waveformLoading"; import WaveformLoading from "../../waveformLoading";
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react"; import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
import LiveTrancription from "../../liveTranscription"; import LiveTrancription from "../../liveTranscription";
import { useTranscriptGet } from "../../../../lib/apiHooks";
import { TranscriptStatus } from "../../../../lib/transcript";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -19,7 +20,7 @@ type TranscriptDetails = {
}; };
const TranscriptRecord = (details: TranscriptDetails) => { const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscript(details.params.transcriptId); const transcript = useTranscriptGet(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
@@ -29,8 +30,8 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const router = useRouter(); const router = useRouter();
const [status, setStatus] = useState( const [status, setStatus] = useState<TranscriptStatus>(
webSockets.status.value || transcript.response?.status || "idle", webSockets.status?.value || transcript.data?.status || "idle",
); );
useEffect(() => { useEffect(() => {
@@ -41,7 +42,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
useEffect(() => { useEffect(() => {
//TODO HANDLE ERROR STATUS BETTER //TODO HANDLE ERROR STATUS BETTER
const newStatus = const newStatus =
webSockets.status.value || transcript.response?.status || "idle"; webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus); setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) { if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting"); console.log(newStatus, "redirecting");
@@ -49,7 +50,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl); router.replace(newUrl);
} }
}, [webSockets.status.value, transcript.response?.status]); }, [webSockets.status?.value, transcript.data?.status]);
useEffect(() => { useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow(); if (webSockets.waveform && webSockets.waveform) mp3.getNow();

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import useMp3 from "../../useMp3"; import useMp3 from "../../useMp3";
import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react"; import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react";
import FileUploadButton from "../../fileUploadButton"; import FileUploadButton from "../../fileUploadButton";
import { useTranscriptGet } from "../../../../lib/apiHooks";
type TranscriptUpload = { type TranscriptUpload = {
params: { params: {
@@ -15,7 +15,7 @@ type TranscriptUpload = {
}; };
const TranscriptUpload = (details: TranscriptUpload) => { const TranscriptUpload = (details: TranscriptUpload) => {
const transcript = useTranscript(details.params.transcriptId); const transcript = useTranscriptGet(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId); const webSockets = useWebSockets(details.params.transcriptId);
@@ -25,13 +25,13 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const router = useRouter(); const router = useRouter();
const [status_, setStatus] = useState( const [status_, setStatus] = useState(
webSockets.status.value || transcript.response?.status || "idle", webSockets.status?.value || transcript.data?.status || "idle",
); );
// status is obviously done if we have transcript // status is obviously done if we have transcript
const status = const status =
!transcript.loading && transcript.response?.status === "ended" !transcript.isLoading && transcript.data?.status === "ended"
? transcript.response?.status ? transcript.data?.status
: status_; : status_;
useEffect(() => { useEffect(() => {
@@ -43,9 +43,9 @@ const TranscriptUpload = (details: TranscriptUpload) => {
//TODO HANDLE ERROR STATUS BETTER //TODO HANDLE ERROR STATUS BETTER
// TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib // TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib
const newStatus = const newStatus =
transcript.response?.status === "ended" transcript.data?.status === "ended"
? "ended" ? "ended"
: webSockets.status.value || transcript.response?.status || "idle"; : webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus); setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) { if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting"); console.log(newStatus, "redirecting");
@@ -53,7 +53,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl); router.replace(newUrl);
} }
}, [webSockets.status.value, transcript.response?.status]); }, [webSockets.status?.value, transcript.data?.status]);
useEffect(() => { useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow(); if (webSockets.waveform && webSockets.waveform) mp3.getNow();

View File

@@ -11,10 +11,11 @@ import useAudioDevice from "./useAudioDevice";
import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react"; import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react";
import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu"; import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu";
import { RECORD_A_MEETING_URL } from "../../api/urls"; import { RECORD_A_MEETING_URL } from "../../api/urls";
import { TranscriptStatus } from "../../lib/transcript";
type RecorderProps = { type RecorderProps = {
transcriptId: string; transcriptId: string;
status: string; status: TranscriptStatus;
}; };
export default function Recorder(props: RecorderProps) { export default function Recorder(props: RecorderProps) {

View File

@@ -1,69 +0,0 @@
import type { components } from "../../reflector-api";
import { useTranscriptGet } from "../../lib/apiHooks";
type GetTranscript = components["schemas"]["GetTranscript"];
type ErrorTranscript = {
error: Error;
loading: false;
response: null;
reload: () => void;
};
type LoadingTranscript = {
response: null;
loading: true;
error: false;
reload: () => void;
};
type SuccessTranscript = {
response: GetTranscript;
loading: false;
error: null;
reload: () => void;
};
const useTranscript = (
id: string | null,
): ErrorTranscript | LoadingTranscript | SuccessTranscript => {
const { data, isLoading, error, refetch } = useTranscriptGet(id);
// Map to the expected return format
if (isLoading) {
return {
response: null,
loading: true,
error: false,
reload: refetch,
};
}
if (error) {
return {
error: error as Error,
loading: false,
response: null,
reload: refetch,
};
}
// Check if data is undefined or null
if (!data) {
return {
response: null,
loading: true,
error: false,
reload: refetch,
};
}
return {
response: data,
loading: false,
error: null,
reload: refetch,
};
};
export default useTranscript;

View File

@@ -16,7 +16,7 @@ export type UseWebSockets = {
title: string; title: string;
topics: Topic[]; topics: Topic[];
finalSummary: FinalSummary; finalSummary: FinalSummary;
status: Status; status: Status | null;
waveform: AudioWaveform | null; waveform: AudioWaveform | null;
duration: number | null; duration: number | null;
}; };
@@ -34,7 +34,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [finalSummary, setFinalSummary] = useState<FinalSummary>({ const [finalSummary, setFinalSummary] = useState<FinalSummary>({
summary: "", summary: "",
}); });
const [status, setStatus] = useState<Status>({ value: "" }); const [status, setStatus] = useState<Status | null>(null);
const { setError } = useError(); const { setError } = useError();
const { websocket_url: websocketUrl } = useContext(DomainContext); const { websocket_url: websocketUrl } = useContext(DomainContext);

View File

@@ -1,4 +1,5 @@
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
import type { TranscriptStatus } from "../../lib/transcript";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
@@ -13,7 +14,7 @@ export type FinalSummary = {
}; };
export type Status = { export type Status = {
value: string; value: TranscriptStatus;
}; };
export type TranslatedTopic = { export type TranslatedTopic = {

View File

@@ -389,7 +389,6 @@ export default function MeetingSelection({
</HStack> </HStack>
</Box> </Box>
)} )}
</Flex> </Flex>
</Flex> </Flex>
); );

View File

@@ -1,9 +1,21 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Button, HStack, Icon, Spinner, Text, VStack } from "@chakra-ui/react"; import {
Box,
Button,
HStack,
Icon,
Spinner,
Text,
VStack,
} from "@chakra-ui/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useRoomGetByName, useRoomJoinMeeting, useMeetingAudioConsent } from "../../lib/apiHooks"; import {
useRoomGetByName,
useRoomJoinMeeting,
useMeetingAudioConsent,
} from "../../lib/apiHooks";
import { useRecordingConsent } from "../../recordingConsentContext"; import { useRecordingConsent } from "../../recordingConsentContext";
import { toaster } from "../../components/ui/toaster"; import { toaster } from "../../components/ui/toaster";
import { FaBars } from "react-icons/fa6"; import { FaBars } from "react-icons/fa6";
@@ -248,8 +260,10 @@ export default function MeetingPage({ params }: MeetingPageProps) {
const joinMeetingMutation = useRoomJoinMeeting(); const joinMeetingMutation = useRoomJoinMeeting();
const room = roomQuery.data; const room = roomQuery.data;
const isLoading = roomQuery.isLoading || (!attemptedJoin && room && !joinMeetingMutation.data); const isLoading =
roomQuery.isLoading ||
(!attemptedJoin && room && !joinMeetingMutation.data);
// Try to join the meeting when room is loaded // Try to join the meeting when room is loaded
useEffect(() => { useEffect(() => {
if (room && !attemptedJoin && !joinMeetingMutation.isPending) { if (room && !attemptedJoin && !joinMeetingMutation.isPending) {
@@ -326,10 +340,7 @@ export default function MeetingPage({ params }: MeetingPageProps) {
style={{ width: "100vw", height: "100vh" }} style={{ width: "100vw", height: "100vh" }}
/> />
{recordingType && recordingTypeRequiresConsent(recordingType) && ( {recordingType && recordingTypeRequiresConsent(recordingType) && (
<ConsentDialogButton <ConsentDialogButton meetingId={meetingId} wherebyRef={wherebyRef} />
meetingId={meetingId}
wherebyRef={wherebyRef}
/>
)} )}
</> </>
); );
@@ -339,10 +350,7 @@ export default function MeetingPage({ params }: MeetingPageProps) {
// But keeping it as a fallback // But keeping it as a fallback
return ( return (
<Box display="flex" flexDirection="column" minH="100vh"> <Box display="flex" flexDirection="column" minH="100vh">
<MinimalHeader <MinimalHeader roomName={roomName} displayName={room?.name} />
roomName={roomName}
displayName={room?.name}
/>
<Box <Box
display="flex" display="flex"
justifyContent="center" justifyContent="center"

View File

@@ -21,7 +21,13 @@ import { toaster } from "../components/ui/toaster";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { useRecordingConsent } from "../recordingConsentContext"; import { useRecordingConsent } from "../recordingConsentContext";
import { useMeetingAudioConsent, useRoomGetByName, useRoomActiveMeetings, useRoomUpcomingMeetings, useRoomsCreateMeeting } from "../lib/apiHooks"; import {
useMeetingAudioConsent,
useRoomGetByName,
useRoomActiveMeetings,
useRoomUpcomingMeetings,
useRoomsCreateMeeting,
} from "../lib/apiHooks";
import type { components } from "../reflector-api"; import type { components } from "../reflector-api";
import MeetingSelection from "./MeetingSelection"; import MeetingSelection from "./MeetingSelection";
import useRoomMeeting from "./useRoomMeeting"; import useRoomMeeting from "./useRoomMeeting";
@@ -281,12 +287,11 @@ export default function Room(details: RoomDetails) {
const roomUrl = const roomUrl =
roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url; roomMeeting?.response?.host_room_url || roomMeeting?.response?.room_url;
const isLoading = status === "loading" || roomQuery.isLoading || roomMeeting?.loading; const isLoading =
status === "loading" || roomQuery.isLoading || roomMeeting?.loading;
const isOwner = const isOwner =
isAuthenticated && room isAuthenticated && room ? auth.user?.id === room.user_id : false;
? auth.user?.id === room.user_id
: false;
const meetingId = roomMeeting?.response?.id; const meetingId = roomMeeting?.response?.id;

View File

@@ -88,8 +88,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}; };
// not useEffect, we need it ASAP // not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired
configureApiAuth( configureApiAuth(
contextValue.status === "authenticated" ? contextValue.accessToken : null, contextValue.status === "authenticated"
? contextValue.accessToken
: contextValue.status === "loading"
? undefined
: null,
); );
return ( return (

View File

@@ -4,18 +4,16 @@ import "@whereby.com/browser-sdk/embed";
import { Box, Button, HStack, Text, Link } from "@chakra-ui/react"; import { Box, Button, HStack, Text, Link } from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster"; import { toaster } from "../components/ui/toaster";
interface WherebyEmbedProps { interface WherebyWebinarEmbedProps {
roomUrl: string; roomUrl: string;
onLeave?: () => void; onLeave?: () => void;
isWebinar?: boolean;
} }
// used for both webinars and meetings // used for webinars only
export default function WherebyWebinarEmbed({ export default function WherebyWebinarEmbed({
roomUrl, roomUrl,
onLeave, onLeave,
isWebinar = false, }: WherebyWebinarEmbedProps) {
}: WherebyEmbedProps) {
const wherebyRef = useRef<HTMLElement>(null); const wherebyRef = useRef<HTMLElement>(null);
// TODO extract common toast logic / styles to be used by consent toast on normal rooms // TODO extract common toast logic / styles to be used by consent toast on normal rooms
@@ -28,8 +26,7 @@ export default function WherebyWebinarEmbed({
<Box p={4} bg="white" borderRadius="md" boxShadow="md"> <Box p={4} bg="white" borderRadius="md" boxShadow="md">
<HStack justifyContent="space-between" alignItems="center"> <HStack justifyContent="space-between" alignItems="center">
<Text> <Text>
This {isWebinar ? "webinar" : "meeting"} is being recorded. By This webinar is being recorded. By continuing, you agree to our{" "}
continuing, you agree to our{" "}
<Link <Link
href="https://monadical.com/privacy" href="https://monadical.com/privacy"
color="blue.600" color="blue.600"

View File

@@ -2,12 +2,6 @@
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import type { paths } from "../reflector-api"; import type { paths } from "../reflector-api";
import {
queryOptions,
useMutation,
useQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
import createFetchClient from "openapi-react-query"; import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils"; import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
@@ -16,18 +10,31 @@ const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL) ? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
: "http://localhost"; : "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<paths>({ export const client = createClient<paths>({
baseUrl: API_URL, baseUrl: API_URL,
}); });
export const $api = createFetchClient<paths>(client); const waitForAuthTokenDefinitivePresenceOrAbscence = async () => {
let tries = 0;
let currentAuthToken: string | null | undefined = null; let time = 0;
const STEP = 100;
while (currentAuthToken === undefined) {
await new Promise((resolve) => setTimeout(resolve, STEP));
time += STEP;
tries++;
// most likely first try is more than enough, if it's more there's already something weird happens
if (tries > 10) {
// even when there's no auth assumed at all, we probably should explicitly call configureApiAuth(null)
throw new Error(
`Could not get auth token definitive presence/absence in ${time}ms. not calling configureApiAuth?`,
);
}
}
};
client.use({ client.use({
onRequest({ request }) { async onRequest({ request }) {
await waitForAuthTokenDefinitivePresenceOrAbscence();
if (currentAuthToken) { if (currentAuthToken) {
request.headers.set("Authorization", `Bearer ${currentAuthToken}`); request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
} }
@@ -44,7 +51,13 @@ client.use({
}, },
}); });
export const $api = createFetchClient<paths>(client);
let currentAuthToken: string | null | undefined = undefined;
// the function contract: lightweight, idempotent // the function contract: lightweight, idempotent
export const configureApiAuth = (token: string | null | undefined) => { export const configureApiAuth = (token: string | null | undefined) => {
// watch only for the initial loading; "reloading" state assumes token presence/absence
if (token === undefined && currentAuthToken !== undefined) return;
currentAuthToken = token; currentAuthToken = token;
}; };

View File

@@ -96,8 +96,6 @@ export function useTranscriptProcess() {
} }
export function useTranscriptGet(transcriptId: string | null) { export function useTranscriptGet(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery( return $api.useQuery(
"get", "get",
"/v1/transcripts/{transcript_id}", "/v1/transcripts/{transcript_id}",
@@ -109,7 +107,7 @@ export function useTranscriptGet(transcriptId: string | null) {
}, },
}, },
{ {
enabled: !!transcriptId && isAuthenticated, enabled: !!transcriptId,
}, },
); );
} }
@@ -292,18 +290,16 @@ export function useTranscriptUploadAudio() {
} }
export function useTranscriptWaveform(transcriptId: string | null) { export function useTranscriptWaveform(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery( return $api.useQuery(
"get", "get",
"/v1/transcripts/{transcript_id}/audio/waveform", "/v1/transcripts/{transcript_id}/audio/waveform",
{ {
params: { params: {
path: { transcript_id: transcriptId || "" }, path: { transcript_id: transcriptId! },
}, },
}, },
{ {
enabled: !!transcriptId && isAuthenticated, enabled: !!transcriptId,
}, },
); );
} }
@@ -316,7 +312,7 @@ export function useTranscriptMP3(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/audio/mp3", "/v1/transcripts/{transcript_id}/audio/mp3",
{ {
params: { params: {
path: { transcript_id: transcriptId || "" }, path: { transcript_id: transcriptId! },
}, },
}, },
{ {
@@ -326,8 +322,6 @@ export function useTranscriptMP3(transcriptId: string | null) {
} }
export function useTranscriptTopics(transcriptId: string | null) { export function useTranscriptTopics(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery( return $api.useQuery(
"get", "get",
"/v1/transcripts/{transcript_id}/topics", "/v1/transcripts/{transcript_id}/topics",
@@ -337,7 +331,7 @@ export function useTranscriptTopics(transcriptId: string | null) {
}, },
}, },
{ {
enabled: !!transcriptId && isAuthenticated, enabled: !!transcriptId,
}, },
); );
} }

View File

@@ -0,0 +1,5 @@
import { components } from "../reflector-api";
type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"];
export type TranscriptStatus = ApiTranscriptStatus;

View File

@@ -1243,8 +1243,17 @@ export interface components {
source_kind: components["schemas"]["SourceKind"]; source_kind: components["schemas"]["SourceKind"];
/** Created At */ /** Created At */
created_at: string; created_at: string;
/** Status */ /**
status: string; * Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Rank */ /** Rank */
rank: number; rank: number;
/** /**

View File

@@ -150,15 +150,7 @@ export default function WebinarPage(details: WebinarDetails) {
if (status === WebinarStatus.Live) { if (status === WebinarStatus.Live) {
return ( return (
<> <>{roomUrl && <WherebyEmbed roomUrl={roomUrl} onLeave={handleLeave} />}</>
{roomUrl && (
<WherebyEmbed
roomUrl={roomUrl}
onLeave={handleLeave}
isWebinar={true}
/>
)}
</>
); );
} }
if (status === WebinarStatus.Ended) { if (status === WebinarStatus.Ended) {