Compare commits

...

8 Commits

Author SHA1 Message Date
dc4b737daa chore(main): release 0.16.0 (#711) 2025-10-24 16:18:49 -06:00
Igor Monadical
0baff7abf7 transcript ui copy button placement (#712)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-24 16:52:02 -04:00
Igor Monadical
962c40e2b6 feat: search date filter (#710)
* search date filter

* search date filter

* search date filter

* search date filter

* pr comment

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-23 20:16:43 -04:00
Igor Monadical
3c4b9f2103 chore: error reporting and naming (#708)
* chore: error reporting and naming

* chore: error reporting and naming

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-22 13:45:08 -04:00
Igor Monadical
c6c035aacf removal of email-verified from /me (#707)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-21 14:49:33 -04:00
c086b91445 chore(main): release 0.15.0 (#706) 2025-10-21 08:30:22 -06:00
Igor Monadical
9a258abc02 feat: api tokens (#705)
* feat: api tokens (vibe)

* self-review

* remove token terminology + pr comments (vibe)

* return email_verified

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-10-20 12:55:25 -04:00
af86c47f1d chore(main): release 0.14.0 (#670) 2025-10-08 14:57:31 -06:00
24 changed files with 968 additions and 98 deletions

View File

@@ -1,4 +1,4 @@
name: Deploy to Amazon ECS name: Build container/push to container registry
on: [workflow_dispatch] on: [workflow_dispatch]

View File

@@ -1,5 +1,36 @@
# Changelog # Changelog
## [0.16.0](https://github.com/Monadical-SAS/reflector/compare/v0.15.0...v0.16.0) (2025-10-24)
### Features
* search date filter ([#710](https://github.com/Monadical-SAS/reflector/issues/710)) ([962c40e](https://github.com/Monadical-SAS/reflector/commit/962c40e2b6428ac42fd10aea926782d7a6f3f902))
## [0.15.0](https://github.com/Monadical-SAS/reflector/compare/v0.14.0...v0.15.0) (2025-10-20)
### Features
* api tokens ([#705](https://github.com/Monadical-SAS/reflector/issues/705)) ([9a258ab](https://github.com/Monadical-SAS/reflector/commit/9a258abc0209b0ac3799532a507ea6a9125d703a))
## [0.14.0](https://github.com/Monadical-SAS/reflector/compare/v0.13.1...v0.14.0) (2025-10-08)
### Features
* Add calendar event data to transcript webhook payload ([#689](https://github.com/Monadical-SAS/reflector/issues/689)) ([5f6910e](https://github.com/Monadical-SAS/reflector/commit/5f6910e5131b7f28f86c9ecdcc57fed8412ee3cd))
* container build for www / github ([#672](https://github.com/Monadical-SAS/reflector/issues/672)) ([969bd84](https://github.com/Monadical-SAS/reflector/commit/969bd84fcc14851d1a101412a0ba115f1b7cde82))
* docker-compose for production frontend ([#664](https://github.com/Monadical-SAS/reflector/issues/664)) ([5bf64b5](https://github.com/Monadical-SAS/reflector/commit/5bf64b5a41f64535e22849b4bb11734d4dbb4aae))
### Bug Fixes
* restore feature boolean logic ([#671](https://github.com/Monadical-SAS/reflector/issues/671)) ([3660884](https://github.com/Monadical-SAS/reflector/commit/36608849ec64e953e3be456172502762e3c33df9))
* security review ([#656](https://github.com/Monadical-SAS/reflector/issues/656)) ([5d98754](https://github.com/Monadical-SAS/reflector/commit/5d98754305c6c540dd194dda268544f6d88bfaf8))
* update transcript list on reprocess ([#676](https://github.com/Monadical-SAS/reflector/issues/676)) ([9a71af1](https://github.com/Monadical-SAS/reflector/commit/9a71af145ee9b833078c78d0c684590ab12e9f0e))
* upgrade nemo toolkit ([#678](https://github.com/Monadical-SAS/reflector/issues/678)) ([eef6dc3](https://github.com/Monadical-SAS/reflector/commit/eef6dc39037329b65804297786d852dddb0557f9))
## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22) ## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22)

View File

@@ -1,3 +1,29 @@
## API Key Management
### Finding Your User ID
```bash
# Get your OAuth sub (user ID) - requires authentication
curl -H "Authorization: Bearer <your_jwt>" http://localhost:1250/v1/me
# Returns: {"sub": "your-oauth-sub-here", "email": "...", ...}
```
### Creating API Keys
```bash
curl -X POST http://localhost:1250/v1/user/api-keys \
-H "Authorization: Bearer <your_jwt>" \
-H "Content-Type: application/json" \
-d '{"name": "My API Key"}'
```
### Using API Keys
```bash
# Use X-API-Key header instead of Authorization
curl -H "X-API-Key: <your_api_key>" http://localhost:1250/v1/transcripts
```
## AWS S3/SQS usage clarification ## AWS S3/SQS usage clarification
Whereby.com uploads recordings directly to our S3 bucket when meetings end. Whereby.com uploads recordings directly to our S3 bucket when meetings end.

View File

@@ -0,0 +1,38 @@
"""add user api keys
Revision ID: 9e3f7b2a4c8e
Revises: dc035ff72fd5
Create Date: 2025-10-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "9e3f7b2a4c8e"
down_revision: Union[str, None] = "dc035ff72fd5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_api_key",
sa.Column("id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("key_hash", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("user_api_key", schema=None) as batch_op:
batch_op.create_index("idx_user_api_key_hash", ["key_hash"], unique=True)
batch_op.create_index("idx_user_api_key_user_id", ["user_id"], unique=False)
def downgrade() -> None:
op.drop_table("user_api_key")

View File

@@ -26,6 +26,7 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout
from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router
from reflector.views.transcripts_websocket import router as transcripts_websocket_router from reflector.views.transcripts_websocket import router as transcripts_websocket_router
from reflector.views.user import router as user_router from reflector.views.user import router as user_router
from reflector.views.user_api_keys import router as user_api_keys_router
from reflector.views.user_websocket import router as user_ws_router from reflector.views.user_websocket import router as user_ws_router
from reflector.views.whereby import router as whereby_router from reflector.views.whereby import router as whereby_router
from reflector.views.zulip import router as zulip_router from reflector.views.zulip import router as zulip_router
@@ -91,6 +92,7 @@ app.include_router(transcripts_websocket_router, prefix="/v1")
app.include_router(transcripts_webrtc_router, prefix="/v1") app.include_router(transcripts_webrtc_router, prefix="/v1")
app.include_router(transcripts_process_router, prefix="/v1") app.include_router(transcripts_process_router, prefix="/v1")
app.include_router(user_router, prefix="/v1") app.include_router(user_router, prefix="/v1")
app.include_router(user_api_keys_router, prefix="/v1")
app.include_router(user_ws_router, prefix="/v1") app.include_router(user_ws_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1") app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1") app.include_router(whereby_router, prefix="/v1")

View File

@@ -1,14 +1,16 @@
from typing import Annotated, Optional from typing import Annotated, List, Optional
from fastapi import Depends, HTTPException from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from pydantic import BaseModel from pydantic import BaseModel
from reflector.db.user_api_keys import user_api_keys_controller
from reflector.logger import logger from reflector.logger import logger
from reflector.settings import settings from reflector.settings import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read() jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read()
jwt_algorithm = settings.AUTH_JWT_ALGORITHM jwt_algorithm = settings.AUTH_JWT_ALGORITHM
@@ -26,7 +28,7 @@ class JWTException(Exception):
class UserInfo(BaseModel): class UserInfo(BaseModel):
sub: str sub: str
email: str email: Optional[str] = None
def __getitem__(self, key): def __getitem__(self, key):
return getattr(self, key) return getattr(self, key)
@@ -58,34 +60,53 @@ def authenticated(token: Annotated[str, Depends(oauth2_scheme)]):
return None return None
def current_user( async def _authenticate_user(
token: Annotated[Optional[str], Depends(oauth2_scheme)], jwt_token: Optional[str],
jwtauth: JWTAuth = Depends(), api_key: Optional[str],
): jwtauth: JWTAuth,
if token is None: ) -> UserInfo | None:
raise HTTPException(status_code=401, detail="Not authenticated") user_infos: List[UserInfo] = []
try: if api_key:
payload = jwtauth.verify_token(token) user_api_key = await user_api_keys_controller.verify_key(api_key)
sub = payload["sub"] if user_api_key:
email = payload["email"] user_infos.append(UserInfo(sub=user_api_key.user_id, email=None))
return UserInfo(sub=sub, email=email)
except JWTError as e:
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication")
if jwt_token:
try:
payload = jwtauth.verify_token(jwt_token)
sub = payload["sub"]
email = payload["email"]
user_infos.append(UserInfo(sub=sub, email=email))
except JWTError as e:
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication")
def current_user_optional( if len(user_infos) == 0:
token: Annotated[Optional[str], Depends(oauth2_scheme)],
jwtauth: JWTAuth = Depends(),
):
# we accept no token, but if one is provided, it must be a valid one.
if token is None:
return None return None
try:
payload = jwtauth.verify_token(token) if len(set([x.sub for x in user_infos])) > 1:
sub = payload["sub"] raise JWTException(
email = payload["email"] status_code=401,
return UserInfo(sub=sub, email=email) detail="Invalid authentication: more than one user provided",
except JWTError as e: )
logger.error(f"JWT error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication") return user_infos[0]
async def current_user(
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
api_key: Annotated[Optional[str], Depends(api_key_header)],
jwtauth: JWTAuth = Depends(),
):
user = await _authenticate_user(jwt_token, api_key, jwtauth)
if user is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
async def current_user_optional(
jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
api_key: Annotated[Optional[str], Depends(api_key_header)],
jwtauth: JWTAuth = Depends(),
):
return await _authenticate_user(jwt_token, api_key, jwtauth)

View File

@@ -29,6 +29,7 @@ import reflector.db.meetings # noqa
import reflector.db.recordings # noqa import reflector.db.recordings # noqa
import reflector.db.rooms # noqa import reflector.db.rooms # noqa
import reflector.db.transcripts # noqa import reflector.db.transcripts # noqa
import reflector.db.user_api_keys # noqa
kwargs = {} kwargs = {}
if "postgres" not in settings.DATABASE_URL: if "postgres" not in settings.DATABASE_URL:

View File

@@ -135,6 +135,8 @@ class SearchParameters(BaseModel):
user_id: str | None = None user_id: str | None = None
room_id: str | None = None room_id: str | None = None
source_kind: SourceKind | None = None source_kind: SourceKind | None = None
from_datetime: datetime | None = None
to_datetime: datetime | None = None
class SearchResultDB(BaseModel): class SearchResultDB(BaseModel):
@@ -402,6 +404,14 @@ class SearchController:
base_query = base_query.where( base_query = base_query.where(
transcripts.c.source_kind == params.source_kind transcripts.c.source_kind == params.source_kind
) )
if params.from_datetime:
base_query = base_query.where(
transcripts.c.created_at >= params.from_datetime
)
if params.to_datetime:
base_query = base_query.where(
transcripts.c.created_at <= params.to_datetime
)
if params.query_text is not None: if params.query_text is not None:
order_by = sqlalchemy.desc(sqlalchemy.text("rank")) order_by = sqlalchemy.desc(sqlalchemy.text("rank"))

View File

@@ -0,0 +1,90 @@
import hmac
import secrets
from datetime import datetime, timezone
from hashlib import sha256
import sqlalchemy
from pydantic import BaseModel, Field
from reflector.db import get_database, metadata
from reflector.settings import settings
from reflector.utils import generate_uuid4
from reflector.utils.string import NonEmptyString
user_api_keys = sqlalchemy.Table(
"user_api_key",
metadata,
sqlalchemy.Column("id", sqlalchemy.String, primary_key=True),
sqlalchemy.Column("user_id", sqlalchemy.String, nullable=False),
sqlalchemy.Column("key_hash", sqlalchemy.String, nullable=False),
sqlalchemy.Column("name", sqlalchemy.String, nullable=True),
sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False),
sqlalchemy.Index("idx_user_api_key_hash", "key_hash", unique=True),
sqlalchemy.Index("idx_user_api_key_user_id", "user_id"),
)
class UserApiKey(BaseModel):
id: NonEmptyString = Field(default_factory=generate_uuid4)
user_id: NonEmptyString
key_hash: NonEmptyString
name: NonEmptyString | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class UserApiKeyController:
@staticmethod
def generate_key() -> NonEmptyString:
return secrets.token_urlsafe(48)
@staticmethod
def hash_key(key: NonEmptyString) -> str:
return hmac.new(
settings.SECRET_KEY.encode(), key.encode(), digestmod=sha256
).hexdigest()
@classmethod
async def create_key(
cls,
user_id: NonEmptyString,
name: NonEmptyString | None = None,
) -> tuple[UserApiKey, NonEmptyString]:
plaintext = cls.generate_key()
api_key = UserApiKey(
user_id=user_id,
key_hash=cls.hash_key(plaintext),
name=name,
)
query = user_api_keys.insert().values(**api_key.model_dump())
await get_database().execute(query)
return api_key, plaintext
@classmethod
async def verify_key(cls, plaintext_key: NonEmptyString) -> UserApiKey | None:
key_hash = cls.hash_key(plaintext_key)
query = user_api_keys.select().where(
user_api_keys.c.key_hash == key_hash,
)
result = await get_database().fetch_one(query)
return UserApiKey(**result) if result else None
@staticmethod
async def list_by_user_id(user_id: NonEmptyString) -> list[UserApiKey]:
query = (
user_api_keys.select()
.where(user_api_keys.c.user_id == user_id)
.order_by(user_api_keys.c.created_at.desc())
)
results = await get_database().fetch_all(query)
return [UserApiKey(**r) for r in results]
@staticmethod
async def delete_key(key_id: NonEmptyString, user_id: NonEmptyString) -> bool:
query = user_api_keys.delete().where(
(user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id)
)
result = await get_database().execute(query)
return result > 0
user_api_keys_controller = UserApiKeyController()

View File

@@ -426,7 +426,12 @@ async def task_pipeline_file_process(*, transcript_id: str):
await pipeline.process(audio_file) await pipeline.process(audio_file)
except Exception: except Exception as e:
logger.error(
f"File pipeline failed for transcript {transcript_id}: {type(e).__name__}: {str(e)}",
exc_info=True,
transcript_id=transcript_id,
)
await pipeline.set_status(transcript_id, "error") await pipeline.set_status(transcript_id, "error")
raise raise

View File

@@ -56,6 +56,16 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
}, },
follow_redirects=True, follow_redirects=True,
) )
if response.status_code != 200:
error_body = response.text
self.logger.error(
"Modal API error",
audio_url=data.audio_url,
status_code=response.status_code,
error_body=error_body,
)
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()

View File

@@ -34,8 +34,16 @@ TOPIC_PROMPT = dedent(
class TopicResponse(BaseModel): class TopicResponse(BaseModel):
"""Structured response for topic detection""" """Structured response for topic detection"""
title: str = Field(description="A descriptive title for the topic being discussed") title: str = Field(
summary: str = Field(description="A concise 1-2 sentence summary of the discussion") description="A descriptive title for the topic being discussed",
validation_alias="Title",
)
summary: str = Field(
description="A concise 1-2 sentence summary of the discussion",
validation_alias="Summary",
)
model_config = {"populate_by_name": True}
class TranscriptTopicDetectorProcessor(Processor): class TranscriptTopicDetectorProcessor(Processor):

View File

@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi_pagination import Page from fastapi_pagination import Page
from fastapi_pagination.ext.databases import apaginate from fastapi_pagination.ext.databases import apaginate
from jose import jwt from jose import jwt
from pydantic import BaseModel, Field, constr, field_serializer from pydantic import AwareDatetime, BaseModel, Field, constr, field_serializer
import reflector.auth as auth import reflector.auth as auth
from reflector.db import get_database from reflector.db import get_database
@@ -133,6 +133,21 @@ SearchOffsetParam = Annotated[
SearchOffsetBase, Query(description="Number of results to skip") SearchOffsetBase, Query(description="Number of results to skip")
] ]
SearchFromDatetimeParam = Annotated[
AwareDatetime | None,
Query(
alias="from",
description="Filter transcripts created on or after this datetime (ISO 8601 with timezone)",
),
]
SearchToDatetimeParam = Annotated[
AwareDatetime | None,
Query(
alias="to",
description="Filter transcripts created on or before this datetime (ISO 8601 with timezone)",
),
]
class SearchResponse(BaseModel): class SearchResponse(BaseModel):
results: list[SearchResult] results: list[SearchResult]
@@ -174,18 +189,23 @@ async def transcripts_search(
offset: SearchOffsetParam = 0, offset: SearchOffsetParam = 0,
room_id: Optional[str] = None, room_id: Optional[str] = None,
source_kind: Optional[SourceKind] = None, source_kind: Optional[SourceKind] = None,
from_datetime: SearchFromDatetimeParam = None,
to_datetime: SearchToDatetimeParam = None,
user: Annotated[ user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional) Optional[auth.UserInfo], Depends(auth.current_user_optional)
] = None, ] = None,
): ):
""" """Full-text search across transcript titles and content."""
Full-text search across transcript titles and content.
"""
if not user and not settings.PUBLIC_MODE: if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
if from_datetime and to_datetime and from_datetime > to_datetime:
raise HTTPException(
status_code=400, detail="'from' must be less than or equal to 'to'"
)
search_params = SearchParameters( search_params = SearchParameters(
query_text=parse_search_query_param(q), query_text=parse_search_query_param(q),
limit=limit, limit=limit,
@@ -193,6 +213,8 @@ async def transcripts_search(
user_id=user_id, user_id=user_id,
room_id=room_id, room_id=room_id,
source_kind=source_kind, source_kind=source_kind,
from_datetime=from_datetime,
to_datetime=to_datetime,
) )
results, total = await search_controller.search_transcripts(search_params) results, total = await search_controller.search_transcripts(search_params)

View File

@@ -11,7 +11,6 @@ router = APIRouter()
class UserInfo(BaseModel): class UserInfo(BaseModel):
sub: str sub: str
email: Optional[str] email: Optional[str]
email_verified: Optional[bool]
@router.get("/me") @router.get("/me")

View File

@@ -0,0 +1,62 @@
from datetime import datetime
from typing import Annotated
import structlog
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.user_api_keys import user_api_keys_controller
from reflector.utils.string import NonEmptyString
router = APIRouter()
logger = structlog.get_logger(__name__)
class CreateApiKeyRequest(BaseModel):
name: NonEmptyString | None = None
class ApiKeyResponse(BaseModel):
id: NonEmptyString
user_id: NonEmptyString
name: NonEmptyString | None
created_at: datetime
class CreateApiKeyResponse(ApiKeyResponse):
key: NonEmptyString
@router.post("/user/api-keys", response_model=CreateApiKeyResponse)
async def create_api_key(
req: CreateApiKeyRequest,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
api_key_model, plaintext = await user_api_keys_controller.create_key(
user_id=user["sub"],
name=req.name,
)
return CreateApiKeyResponse(
**api_key_model.model_dump(),
key=plaintext,
)
@router.get("/user/api-keys", response_model=list[ApiKeyResponse])
async def list_api_keys(
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
api_keys = await user_api_keys_controller.list_by_user_id(user["sub"])
return [ApiKeyResponse(**k.model_dump()) for k in api_keys]
@router.delete("/user/api-keys/{key_id}")
async def delete_api_key(
key_id: NonEmptyString,
user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
deleted = await user_api_keys_controller.delete_key(key_id, user["sub"])
if not deleted:
raise HTTPException(status_code=404)
return {"status": "ok"}

View File

@@ -0,0 +1,256 @@
from datetime import datetime, timedelta, timezone
import pytest
from reflector.db import get_database
from reflector.db.search import SearchParameters, search_controller
from reflector.db.transcripts import SourceKind, transcripts
@pytest.mark.asyncio
class TestDateRangeIntegration:
async def setup_test_transcripts(self):
# Use a test user_id that will match in our search parameters
test_user_id = "test-user-123"
test_data = [
{
"id": "test-before-range",
"created_at": datetime(2024, 1, 15, tzinfo=timezone.utc),
"title": "Before Range Transcript",
"user_id": test_user_id,
},
{
"id": "test-start-boundary",
"created_at": datetime(2024, 6, 1, tzinfo=timezone.utc),
"title": "Start Boundary Transcript",
"user_id": test_user_id,
},
{
"id": "test-middle-range",
"created_at": datetime(2024, 6, 15, tzinfo=timezone.utc),
"title": "Middle Range Transcript",
"user_id": test_user_id,
},
{
"id": "test-end-boundary",
"created_at": datetime(2024, 6, 30, 23, 59, 59, tzinfo=timezone.utc),
"title": "End Boundary Transcript",
"user_id": test_user_id,
},
{
"id": "test-after-range",
"created_at": datetime(2024, 12, 31, tzinfo=timezone.utc),
"title": "After Range Transcript",
"user_id": test_user_id,
},
]
for data in test_data:
full_data = {
"id": data["id"],
"name": data["id"],
"status": "ended",
"locked": False,
"duration": 60.0,
"created_at": data["created_at"],
"title": data["title"],
"short_summary": "Test summary",
"long_summary": "Test long summary",
"share_mode": "public",
"source_kind": SourceKind.FILE,
"audio_deleted": False,
"reviewed": False,
"user_id": data["user_id"],
}
await get_database().execute(transcripts.insert().values(**full_data))
return test_data
async def cleanup_test_transcripts(self, test_data):
"""Clean up test transcripts."""
for data in test_data:
await get_database().execute(
transcripts.delete().where(transcripts.c.id == data["id"])
)
@pytest.mark.asyncio
async def test_filter_with_from_datetime_only(self):
"""Test filtering with only from_datetime parameter."""
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
to_datetime=None,
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
# Should include: start_boundary, middle, end_boundary, after
result_ids = [r.id for r in results]
assert "test-before-range" not in result_ids
assert "test-start-boundary" in result_ids
assert "test-middle-range" in result_ids
assert "test-end-boundary" in result_ids
assert "test-after-range" in result_ids
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_filter_with_to_datetime_only(self):
"""Test filtering with only to_datetime parameter."""
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=None,
to_datetime=datetime(2024, 6, 30, tzinfo=timezone.utc),
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
result_ids = [r.id for r in results]
assert "test-before-range" in result_ids
assert "test-start-boundary" in result_ids
assert "test-middle-range" in result_ids
assert "test-end-boundary" not in result_ids
assert "test-after-range" not in result_ids
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_filter_with_both_datetimes(self):
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
to_datetime=datetime(
2024, 7, 1, tzinfo=timezone.utc
), # Inclusive of 6/30
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
result_ids = [r.id for r in results]
assert "test-before-range" not in result_ids
assert "test-start-boundary" in result_ids
assert "test-middle-range" in result_ids
assert "test-end-boundary" in result_ids
assert "test-after-range" not in result_ids
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_date_filter_with_room_and_source_kind(self):
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
to_datetime=datetime(2024, 7, 1, tzinfo=timezone.utc),
source_kind=SourceKind.FILE,
room_id=None,
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
for result in results:
assert result.source_kind == SourceKind.FILE
assert result.created_at >= datetime(2024, 6, 1, tzinfo=timezone.utc)
assert result.created_at <= datetime(2024, 7, 1, tzinfo=timezone.utc)
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_empty_results_for_future_dates(self):
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
params = SearchParameters(
query_text=None,
from_datetime=datetime(2099, 1, 1, tzinfo=timezone.utc),
to_datetime=datetime(2099, 12, 31, tzinfo=timezone.utc),
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
assert results == []
assert total == 0
finally:
await self.cleanup_test_transcripts(test_data)
@pytest.mark.asyncio
async def test_date_only_input_handling(self):
test_data = await self.setup_test_transcripts()
test_user_id = "test-user-123"
try:
# Pydantic will parse date-only strings to datetime at midnight
from_dt = datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)
to_dt = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
params = SearchParameters(
query_text=None,
from_datetime=from_dt,
to_datetime=to_dt,
user_id=test_user_id,
)
results, total = await search_controller.search_transcripts(params)
result_ids = [r.id for r in results]
assert "test-middle-range" in result_ids
assert "test-before-range" not in result_ids
assert "test-after-range" not in result_ids
finally:
await self.cleanup_test_transcripts(test_data)
class TestDateValidationEdgeCases:
"""Edge case tests for datetime validation."""
def test_timezone_aware_comparison(self):
"""Test that timezone-aware comparisons work correctly."""
# PST time (UTC-8)
pst = timezone(timedelta(hours=-8))
pst_dt = datetime(2024, 6, 15, 8, 0, 0, tzinfo=pst)
# UTC time equivalent (8AM PST = 4PM UTC)
utc_dt = datetime(2024, 6, 15, 16, 0, 0, tzinfo=timezone.utc)
assert pst_dt == utc_dt
def test_mixed_timezone_input(self):
"""Test handling mixed timezone inputs."""
pst = timezone(timedelta(hours=-8))
ist = timezone(timedelta(hours=5, minutes=30))
from_date = datetime(2024, 6, 15, 0, 0, 0, tzinfo=pst) # PST midnight
to_date = datetime(2024, 6, 15, 23, 59, 59, tzinfo=ist) # IST end of day
assert from_date.tzinfo is not None
assert to_date.tzinfo is not None
assert from_date < to_date

View File

@@ -0,0 +1,70 @@
import pytest
from reflector.db.user_api_keys import user_api_keys_controller
@pytest.mark.asyncio
async def test_api_key_creation_and_verification():
api_key_model, plaintext = await user_api_keys_controller.create_key(
user_id="test_user",
name="Test API Key",
)
verified = await user_api_keys_controller.verify_key(plaintext)
assert verified is not None
assert verified.user_id == "test_user"
assert verified.name == "Test API Key"
invalid = await user_api_keys_controller.verify_key("fake_key")
assert invalid is None
@pytest.mark.asyncio
async def test_api_key_hashing():
_, plaintext = await user_api_keys_controller.create_key(
user_id="test_user_2",
)
api_keys = await user_api_keys_controller.list_by_user_id("test_user_2")
assert len(api_keys) == 1
assert api_keys[0].key_hash != plaintext
@pytest.mark.asyncio
async def test_generate_api_key_uniqueness():
key1 = user_api_keys_controller.generate_key()
key2 = user_api_keys_controller.generate_key()
assert key1 != key2
@pytest.mark.asyncio
async def test_hash_api_key_deterministic():
key = "test_key_123"
hash1 = user_api_keys_controller.hash_key(key)
hash2 = user_api_keys_controller.hash_key(key)
assert hash1 == hash2
@pytest.mark.asyncio
async def test_get_by_user_id_empty():
api_keys = await user_api_keys_controller.list_by_user_id("nonexistent_user")
assert api_keys == []
@pytest.mark.asyncio
async def test_get_by_user_id_multiple():
user_id = "multi_key_user"
_, plaintext1 = await user_api_keys_controller.create_key(
user_id=user_id,
name="API Key 1",
)
_, plaintext2 = await user_api_keys_controller.create_key(
user_id=user_id,
name="API Key 2",
)
api_keys = await user_api_keys_controller.list_by_user_id(user_id)
assert len(api_keys) == 2
names = {k.name for k in api_keys}
assert names == {"API Key 1", "API Key 2"}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useState } from "react";
import React from "react"; import React from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import "../../../styles/markdown.css"; import "../../../styles/markdown.css";
@@ -16,17 +16,15 @@ import {
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { LuPen } from "react-icons/lu"; import { LuPen } from "react-icons/lu";
import { useError } from "../../../(errors)/errorContext"; import { useError } from "../../../(errors)/errorContext";
import ShareAndPrivacy from "../shareAndPrivacy";
type FinalSummaryProps = { type FinalSummaryProps = {
transcriptResponse: GetTranscript; transcript: GetTranscript;
topicsResponse: GetTranscriptTopic[]; topics: GetTranscriptTopic[];
onUpdate?: (newSummary) => void; onUpdate: (newSummary: string) => void;
finalSummaryRef: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>;
}; };
export default function FinalSummary(props: FinalSummaryProps) { export default function FinalSummary(props: FinalSummaryProps) {
const finalSummaryRef = useRef<HTMLParagraphElement>(null);
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const [preEditSummary, setPreEditSummary] = useState(""); const [preEditSummary, setPreEditSummary] = useState("");
const [editedSummary, setEditedSummary] = useState(""); const [editedSummary, setEditedSummary] = useState("");
@@ -35,10 +33,10 @@ export default function FinalSummary(props: FinalSummaryProps) {
const updateTranscriptMutation = useTranscriptUpdate(); const updateTranscriptMutation = useTranscriptUpdate();
useEffect(() => { useEffect(() => {
setEditedSummary(props.transcriptResponse?.long_summary || ""); setEditedSummary(props.transcript?.long_summary || "");
}, [props.transcriptResponse?.long_summary]); }, [props.transcript?.long_summary]);
if (!props.topicsResponse || !props.transcriptResponse) { if (!props.topics || !props.transcript) {
return null; return null;
} }
@@ -54,9 +52,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
long_summary: newSummary, long_summary: newSummary,
}, },
}); });
if (props.onUpdate) { props.onUpdate(newSummary);
props.onUpdate(newSummary);
}
console.log("Updated long summary:", updatedTranscript); console.log("Updated long summary:", updatedTranscript);
} catch (err) { } catch (err) {
console.error("Failed to update long summary:", err); console.error("Failed to update long summary:", err);
@@ -75,7 +71,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
}; };
const onSaveClick = () => { const onSaveClick = () => {
updateSummary(editedSummary, props.transcriptResponse.id); updateSummary(editedSummary, props.transcript.id);
setIsEditMode(false); setIsEditMode(false);
}; };
@@ -133,11 +129,6 @@ export default function FinalSummary(props: FinalSummaryProps) {
> >
<LuPen /> <LuPen />
</IconButton> </IconButton>
<ShareAndPrivacy
finalSummaryRef={finalSummaryRef}
transcriptResponse={props.transcriptResponse}
topicsResponse={props.topicsResponse}
/>
</> </>
)} )}
</Flex> </Flex>
@@ -153,7 +144,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
mt={2} mt={2}
/> />
) : ( ) : (
<div ref={finalSummaryRef} className="markdown"> <div ref={props.finalSummaryRef} className="markdown">
<Markdown>{editedSummary}</Markdown> <Markdown>{editedSummary}</Markdown>
</div> </div>
)} )}

View File

@@ -41,6 +41,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
waiting || mp3.audioDeleted === true, waiting || mp3.audioDeleted === true,
); );
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
const [finalSummaryElement, setFinalSummaryElement] =
useState<HTMLDivElement | null>(null);
useEffect(() => { useEffect(() => {
if (waiting) { if (waiting) {
@@ -124,9 +126,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
<TranscriptTitle <TranscriptTitle
title={transcript.data?.title || "Unnamed Transcript"} title={transcript.data?.title || "Unnamed Transcript"}
transcriptId={transcriptId} transcriptId={transcriptId}
onUpdate={(newTitle) => { onUpdate={() => {
transcript.refetch().then(() => {}); transcript.refetch().then(() => {});
}} }}
transcript={transcript.data || null}
topics={topics.topics}
finalSummaryElement={finalSummaryElement}
/> />
</Flex> </Flex>
{mp3.audioDeleted && ( {mp3.audioDeleted && (
@@ -148,11 +153,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
{transcript.data && topics.topics ? ( {transcript.data && topics.topics ? (
<> <>
<FinalSummary <FinalSummary
transcriptResponse={transcript.data} transcript={transcript.data}
topicsResponse={topics.topics} topics={topics.topics}
onUpdate={() => { onUpdate={() => {
transcript.refetch(); transcript.refetch().then(() => {});
}} }}
finalSummaryRef={setFinalSummaryElement}
/> />
</> </>
) : ( ) : (

View File

@@ -26,9 +26,9 @@ import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features"; import { featureEnabled } from "../../lib/features";
type ShareAndPrivacyProps = { type ShareAndPrivacyProps = {
finalSummaryRef: any; finalSummaryElement: HTMLDivElement | null;
transcriptResponse: GetTranscript; transcript: GetTranscript;
topicsResponse: GetTranscriptTopic[]; topics: GetTranscriptTopic[];
}; };
type ShareOption = { value: ShareMode; label: string }; type ShareOption = { value: ShareMode; label: string };
@@ -48,7 +48,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [isOwner, setIsOwner] = useState(false); const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState<ShareOption>( const [shareMode, setShareMode] = useState<ShareOption>(
shareOptionsData.find( shareOptionsData.find(
(option) => option.value === props.transcriptResponse.share_mode, (option) => option.value === props.transcript.share_mode,
) || shareOptionsData[0], ) || shareOptionsData[0],
); );
const [shareLoading, setShareLoading] = useState(false); const [shareLoading, setShareLoading] = useState(false);
@@ -70,7 +70,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
try { try {
const updatedTranscript = await updateTranscriptMutation.mutateAsync({ const updatedTranscript = await updateTranscriptMutation.mutateAsync({
params: { params: {
path: { transcript_id: props.transcriptResponse.id }, path: { transcript_id: props.transcript.id },
}, },
body: requestBody, body: requestBody,
}); });
@@ -90,8 +90,8 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const userId = auth.status === "authenticated" ? auth.user?.id : null; const userId = auth.status === "authenticated" ? auth.user?.id : null;
useEffect(() => { useEffect(() => {
setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id)); setIsOwner(!!(requireLogin && userId === props.transcript.user_id));
}, [userId, props.transcriptResponse.user_id]); }, [userId, props.transcript.user_id]);
return ( return (
<> <>
@@ -171,19 +171,19 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
<Flex gap={2} mb={2}> <Flex gap={2} mb={2}>
{requireLogin && ( {requireLogin && (
<ShareZulip <ShareZulip
transcriptResponse={props.transcriptResponse} transcript={props.transcript}
topicsResponse={props.topicsResponse} topics={props.topics}
disabled={toShareMode(shareMode.value) === "private"} disabled={toShareMode(shareMode.value) === "private"}
/> />
)} )}
<ShareCopy <ShareCopy
finalSummaryRef={props.finalSummaryRef} finalSummaryElement={props.finalSummaryElement}
transcriptResponse={props.transcriptResponse} transcript={props.transcript}
topicsResponse={props.topicsResponse} topics={props.topics}
/> />
</Flex> </Flex>
<ShareLink transcriptId={props.transcriptResponse.id} /> <ShareLink transcriptId={props.transcript.id} />
</Dialog.Body> </Dialog.Body>
</Dialog.Content> </Dialog.Content>
</Dialog.Positioner> </Dialog.Positioner>

View File

@@ -5,34 +5,35 @@ type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { Button, BoxProps, Box } from "@chakra-ui/react"; import { Button, BoxProps, Box } from "@chakra-ui/react";
type ShareCopyProps = { type ShareCopyProps = {
finalSummaryRef: any; finalSummaryElement: HTMLDivElement | null;
transcriptResponse: GetTranscript; transcript: GetTranscript;
topicsResponse: GetTranscriptTopic[]; topics: GetTranscriptTopic[];
}; };
export default function ShareCopy({ export default function ShareCopy({
finalSummaryRef, finalSummaryElement,
transcriptResponse, transcript,
topicsResponse, topics,
...boxProps ...boxProps
}: ShareCopyProps & BoxProps) { }: ShareCopyProps & BoxProps) {
const [isCopiedSummary, setIsCopiedSummary] = useState(false); const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false); const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
const onCopySummaryClick = () => { const onCopySummaryClick = () => {
let text_to_copy = finalSummaryRef.current?.innerText; const text_to_copy = finalSummaryElement?.innerText;
text_to_copy && if (text_to_copy) {
navigator.clipboard.writeText(text_to_copy).then(() => { navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedSummary(true); setIsCopiedSummary(true);
// Reset the copied state after 2 seconds // Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedSummary(false), 2000); setTimeout(() => setIsCopiedSummary(false), 2000);
}); });
}
}; };
const onCopyTranscriptClick = () => { const onCopyTranscriptClick = () => {
let text_to_copy = let text_to_copy =
topicsResponse topics
?.map((topic) => topic.transcript) ?.map((topic) => topic.transcript)
.join("\n\n") .join("\n\n")
.replace(/ +/g, " ") .replace(/ +/g, " ")

View File

@@ -26,8 +26,8 @@ import {
import { featureEnabled } from "../../lib/features"; import { featureEnabled } from "../../lib/features";
type ShareZulipProps = { type ShareZulipProps = {
transcriptResponse: GetTranscript; transcript: GetTranscript;
topicsResponse: GetTranscriptTopic[]; topics: GetTranscriptTopic[];
disabled: boolean; disabled: boolean;
}; };
@@ -88,14 +88,14 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
}, [stream, streams]); }, [stream, streams]);
const handleSendToZulip = async () => { const handleSendToZulip = async () => {
if (!props.transcriptResponse) return; if (!props.transcript) return;
if (stream && topic) { if (stream && topic) {
try { try {
await postToZulipMutation.mutateAsync({ await postToZulipMutation.mutateAsync({
params: { params: {
path: { path: {
transcript_id: props.transcriptResponse.id, transcript_id: props.transcript.id,
}, },
query: { query: {
stream, stream,

View File

@@ -2,14 +2,22 @@ import { useState } from "react";
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
type UpdateTranscript = components["schemas"]["UpdateTranscript"]; type UpdateTranscript = components["schemas"]["UpdateTranscript"];
type GetTranscript = components["schemas"]["GetTranscript"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { useTranscriptUpdate } from "../../lib/apiHooks"; import { useTranscriptUpdate } from "../../lib/apiHooks";
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react"; import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
import { LuPen } from "react-icons/lu"; import { LuPen } from "react-icons/lu";
import ShareAndPrivacy from "./shareAndPrivacy";
type TranscriptTitle = { type TranscriptTitle = {
title: string; title: string;
transcriptId: string; transcriptId: string;
onUpdate?: (newTitle: string) => void; onUpdate: (newTitle: string) => void;
// share props
transcript: GetTranscript | null;
topics: GetTranscriptTopic[] | null;
finalSummaryElement: HTMLDivElement | null;
}; };
const TranscriptTitle = (props: TranscriptTitle) => { const TranscriptTitle = (props: TranscriptTitle) => {
@@ -29,9 +37,7 @@ const TranscriptTitle = (props: TranscriptTitle) => {
}, },
body: requestBody, body: requestBody,
}); });
if (props.onUpdate) { props.onUpdate(newTitle);
props.onUpdate(newTitle);
}
console.log("Updated transcript title:", newTitle); console.log("Updated transcript title:", newTitle);
} catch (err) { } catch (err) {
console.error("Failed to update transcript:", err); console.error("Failed to update transcript:", err);
@@ -62,11 +68,11 @@ const TranscriptTitle = (props: TranscriptTitle) => {
} }
setIsEditing(false); setIsEditing(false);
}; };
const handleChange = (e) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setDisplayedTitle(e.target.value); setDisplayedTitle(e.target.value);
}; };
const handleKeyDown = (e) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") { if (e.key === "Enter") {
updateTitle(displayedTitle, props.transcriptId); updateTitle(displayedTitle, props.transcriptId);
setIsEditing(false); setIsEditing(false);
@@ -111,6 +117,13 @@ const TranscriptTitle = (props: TranscriptTitle) => {
> >
<LuPen /> <LuPen />
</IconButton> </IconButton>
{props.transcript && props.topics && (
<ShareAndPrivacy
finalSummaryElement={props.finalSummaryElement}
transcript={props.transcript}
topics={props.topics}
/>
)}
</Flex> </Flex>
)} )}
</> </>

View File

@@ -4,6 +4,23 @@
*/ */
export interface paths { export interface paths {
"/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Health */
get: operations["health"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/metrics": { "/metrics": {
parameters: { parameters: {
query?: never; query?: never;
@@ -587,6 +604,41 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/user/api-keys": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Api Keys */
get: operations["v1_list_api_keys"];
put?: never;
/** Create Api Key */
post: operations["v1_create_api_key"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/user/api-keys/{key_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Api Key */
delete: operations["v1_delete_api_key"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/zulip/streams": { "/v1/zulip/streams": {
parameters: { parameters: {
query?: never; query?: never;
@@ -648,6 +700,26 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
/** ApiKeyResponse */
ApiKeyResponse: {
/**
* Id
* @description A non-empty string
*/
id: string;
/**
* User Id
* @description A non-empty string
*/
user_id: string;
/** Name */
name: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** AudioWaveform */ /** AudioWaveform */
AudioWaveform: { AudioWaveform: {
/** Data */ /** Data */
@@ -707,6 +779,36 @@ export interface components {
*/ */
updated_at: string; updated_at: string;
}; };
/** CreateApiKeyRequest */
CreateApiKeyRequest: {
/** Name */
name?: string | null;
};
/** CreateApiKeyResponse */
CreateApiKeyResponse: {
/**
* Id
* @description A non-empty string
*/
id: string;
/**
* User Id
* @description A non-empty string
*/
user_id: string;
/** Name */
name: string | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Key
* @description A non-empty string
*/
key: string;
};
/** CreateParticipant */ /** CreateParticipant */
CreateParticipant: { CreateParticipant: {
/** Speaker */ /** Speaker */
@@ -1431,8 +1533,6 @@ export interface components {
sub: string; sub: string;
/** Email */ /** Email */
email: string | null; email: string | null;
/** Email Verified */
email_verified: boolean | null;
}; };
/** ValidationError */ /** ValidationError */
ValidationError: { ValidationError: {
@@ -1509,6 +1609,26 @@ export interface components {
} }
export type $defs = Record<string, never>; export type $defs = Record<string, never>;
export interface operations { export interface operations {
health: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
metrics: { metrics: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2158,6 +2278,10 @@ export interface operations {
offset?: number; offset?: number;
room_id?: string | null; room_id?: string | null;
source_kind?: components["schemas"]["SourceKind"] | null; source_kind?: components["schemas"]["SourceKind"] | null;
/** @description Filter transcripts created on or after this datetime (ISO 8601 with timezone) */
from?: string | null;
/** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */
to?: string | null;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -2899,6 +3023,90 @@ export interface operations {
}; };
}; };
}; };
v1_list_api_keys: {
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"]["ApiKeyResponse"][];
};
};
};
};
v1_create_api_key: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateApiKeyRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CreateApiKeyResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_delete_api_key: {
parameters: {
query?: never;
header?: never;
path: {
key_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_zulip_get_streams: { v1_zulip_get_streams: {
parameters: { parameters: {
query?: never; query?: never;