Merge branch 'main' into feat/speaches-integration

This commit is contained in:
Abhishek Kumar 2026-03-28 13:52:28 +05:30
commit 2eaaabd936
20 changed files with 357 additions and 45 deletions

View file

@ -1,3 +1,3 @@
{
".": "1.19.1"
".": "1.19.2"
}

View file

@ -1,5 +1,12 @@
# Changelog
## [1.19.2](https://github.com/dograh-hq/dograh/compare/dograh-v1.19.1...dograh-v1.19.2) (2026-03-26)
### Bug Fixes
* send auth credentials with validate service keys ([83f05ab](https://github.com/dograh-hq/dograh/commit/83f05ab1466d7fa1825b30eb87a267aa1da9ff26))
## [1.19.1](https://github.com/dograh-hq/dograh/compare/dograh-v1.19.0...dograh-v1.19.1) (2026-03-26)

View file

@ -365,12 +365,29 @@ class CampaignClient(BaseDBClient):
result = await session.execute(query)
return list(result.scalars().all())
async def get_completed_runs_for_report(self, campaign_id: int) -> list:
async def get_completed_runs_for_report(
self,
campaign_id: int,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
) -> list:
"""Get completed workflow runs for campaign report CSV.
Returns rows with only the columns needed for report generation.
"""
async with self.async_session() as session:
conditions = [
WorkflowRunModel.campaign_id == campaign_id,
WorkflowRunModel.is_completed.is_(True),
WorkflowRunModel.cost_info["call_duration_seconds"]
.as_string()
.isnot(None),
]
if start_date is not None:
conditions.append(WorkflowRunModel.created_at >= start_date)
if end_date is not None:
conditions.append(WorkflowRunModel.created_at <= end_date)
query = (
select(
WorkflowRunModel.id,
@ -381,13 +398,7 @@ class CampaignClient(BaseDBClient):
WorkflowRunModel.logs,
WorkflowRunModel.public_access_token,
)
.where(
WorkflowRunModel.campaign_id == campaign_id,
WorkflowRunModel.is_completed.is_(True),
WorkflowRunModel.cost_info["call_duration_seconds"]
.as_string()
.isnot(None),
)
.where(*conditions)
.order_by(WorkflowRunModel.created_at.desc())
)
result = await session.execute(query)

View file

@ -465,7 +465,7 @@ class WorkflowClient(BaseDBClient):
return
existing = workflow.call_disposition_codes or {}
codes = existing.get("disposition_codes", [])
codes = list(existing.get("disposition_codes", []))
if disposition_code in codes:
return

View file

@ -1,5 +1,5 @@
[project]
name = "dograh-api"
version = "1.19.1"
version = "1.19.2"
description = "Backend API for Dograh voice AI platform"
requires-python = ">=3.12"

View file

@ -706,13 +706,21 @@ async def get_campaign_source_download_url(
async def download_campaign_report(
campaign_id: int,
user: UserModel = Depends(get_user),
start_date: Optional[datetime] = Query(
None, description="Filter runs created on or after this datetime (ISO 8601)"
),
end_date: Optional[datetime] = Query(
None, description="Filter runs created on or before this datetime (ISO 8601)"
),
) -> StreamingResponse:
"""Download a CSV report of completed campaign runs."""
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
output, filename = await generate_campaign_report_csv(campaign_id)
output, filename = await generate_campaign_report_csv(
campaign_id, start_date=start_date, end_date=end_date
)
return StreamingResponse(
output,

View file

@ -125,7 +125,11 @@ async def update_user_configurations(
try:
validator = UserConfigurationValidator()
await validator.validate(user_configurations)
await validator.validate(
user_configurations,
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])
@ -163,7 +167,11 @@ async def validate_user_configurations(
):
validator = UserConfigurationValidator()
try:
status = await validator.validate(configurations)
status = await validator.validate(
configurations,
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
await db_client.update_user_configuration_last_validated_at(user.id)
return status
except ValueError as e:

View file

@ -228,7 +228,7 @@ class SignalingManager:
{
"type": "error",
"payload": {
"error_type": "quota_exceeded",
"error_type": quota_result.error_code,
"message": quota_result.error_message,
},
}

View file

@ -1,6 +1,7 @@
import csv
import io
from typing import Any, List
from datetime import datetime
from typing import Any, List, Optional
from api.constants import BACKEND_API_ENDPOINT
from api.db import db_client
@ -27,12 +28,18 @@ def _collect_extracted_variable_keys(runs: List[Any]) -> list[str]:
return list(keys)
async def generate_campaign_report_csv(campaign_id: int) -> tuple[io.StringIO, str]:
async def generate_campaign_report_csv(
campaign_id: int,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
) -> tuple[io.StringIO, str]:
"""Generate a CSV report for a campaign.
Returns a tuple of (csv_output, filename).
"""
runs = await db_client.get_completed_runs_for_report(campaign_id)
runs = await db_client.get_completed_runs_for_report(
campaign_id, start_date=start_date, end_date=end_date
)
# Collect dynamic extracted variable columns
extracted_var_keys = _collect_extracted_variable_keys(runs)

View file

@ -14,6 +14,12 @@ from api.schemas.user_configuration import (
from api.services.configuration.registry import ServiceConfig, ServiceProviders
from api.services.mps_service_key_client import mps_service_key_client
AuthContext = TypedDict(
"AuthContext",
{"organization_id": Optional[int], "created_by": Optional[str]},
total=False,
)
class APIKeyStatus(TypedDict):
model: str
@ -43,7 +49,16 @@ class UserConfigurationValidator:
ServiceProviders.SPEACHES.value: self._check_speaches_api_key,
}
async def validate(self, configuration: UserConfiguration) -> APIKeyStatusResponse:
async def validate(
self,
configuration: UserConfiguration,
organization_id: Optional[int] = None,
created_by: Optional[str] = None,
) -> APIKeyStatusResponse:
self._auth_context: AuthContext = {
"organization_id": organization_id,
"created_by": created_by,
}
status_list = []
status_list.extend(self._validate_service(configuration.llm, "llm"))
@ -165,7 +180,12 @@ class UserConfigurationValidator:
"You provided a Dograh API key (dgr...) instead of a service key. "
"Please use a service key (mps...)."
)
return mps_service_key_client.validate_service_key(api_key)
auth = getattr(self, "_auth_context", {})
return mps_service_key_client.validate_service_key(
api_key,
organization_id=auth.get("organization_id"),
created_by=auth.get("created_by"),
)
def _check_sarvam_api_key(self, model: str, api_key: str) -> bool:
return True

View file

@ -276,7 +276,7 @@ class MPSServiceKeyClient:
"remaining_credits": data.get("remaining_credits", 0.0),
}
else:
logger.error(
logger.warning(
f"Failed to check service key usage: {response.status_code} - {response.text}"
)
raise httpx.HTTPStatusError(
@ -416,7 +416,12 @@ class MPSServiceKeyClient:
response=response,
)
def validate_service_key(self, service_key: str) -> bool:
def validate_service_key(
self,
service_key: str,
organization_id: Optional[int] = None,
created_by: Optional[str] = None,
) -> bool:
"""
Synchronously validate a Dograh service key by checking usage via MPS.
@ -427,7 +432,7 @@ class MPSServiceKeyClient:
response = client.post(
f"{self.base_url}/api/v1/service-keys/usage",
json={"service_key": service_key},
headers=self._get_headers(),
headers=self._get_headers(organization_id, created_by),
)
return response.status_code == 200
except Exception:

View file

@ -20,6 +20,7 @@ class QuotaCheckResult:
has_quota: bool
error_message: str = ""
error_code: str = ""
async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
@ -76,6 +77,7 @@ async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
)
return QuotaCheckResult(
has_quota=False,
error_code="quota_exceeded",
error_message=(
"You have exhausted your trial credits. "
"Please email founders@dograh.com for additional Dograh credits "
@ -89,8 +91,16 @@ async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
)
except Exception as e:
logger.error(f"Failed to check quota for Dograh key: {str(e)}")
error_str = str(e)
if "404" in error_str or "not found" in error_str.lower():
return QuotaCheckResult(
has_quota=False,
error_code="invalid_service_key",
error_message="You have invalid keys in your model configuration. Please validate the service keys.",
)
return QuotaCheckResult(
has_quota=False,
error_code="quota_check_failed",
error_message="Could not verify Dograh credits. Please try again.",
)

View file

@ -0,0 +1,85 @@
"""Test that add_call_disposition_code correctly persists changes.
The bug: `codes` is a reference to the list inside the JSON column value.
Calling `codes.append()` mutates the in-memory column value in-place.
When SQLAlchemy compares old vs new on commit, it sees them as equal
because the old value was already mutated so the change is silently dropped.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from api.db.workflow_client import WorkflowClient
def _make_workflow_stub(initial_disposition_codes):
"""Create a mock workflow that behaves like a SQLAlchemy model instance.
Tracks attribute assignments so we can verify the new value is genuinely
different from the original (which is what SQLAlchemy needs to detect a change).
"""
workflow = MagicMock()
# Store the initial value and track what gets assigned
workflow.call_disposition_codes = initial_disposition_codes
workflow._assigned_values = {}
original_setattr = type(workflow).__setattr__
def tracking_setattr(self, name, value):
if name == "call_disposition_codes":
self._assigned_values[name] = value
original_setattr(self, name, value)
type(workflow).__setattr__ = tracking_setattr
return workflow
@pytest.fixture
def client():
with patch("api.db.workflow_client.BaseDBClient.__init__", return_value=None):
c = WorkflowClient()
c.async_session = MagicMock()
return c
def test_disposition_code_new_value_is_not_same_reference(client):
"""The assigned list must NOT be the same object as the original.
If it is, SQLAlchemy won't detect the change because old == new
(the old was mutated in-place).
"""
initial_codes = {"disposition_codes": ["existing_code"]}
original_list = initial_codes["disposition_codes"]
workflow = MagicMock()
workflow.call_disposition_codes = initial_codes
# Mock the session and query
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = workflow
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session.commit = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
client.async_session = MagicMock(return_value=mock_session)
asyncio.get_event_loop().run_until_complete(
client.add_call_disposition_code(workflow_id=1, disposition_code="new_code")
)
# Verify the disposition code was added
assigned = workflow.call_disposition_codes
assert "new_code" in assigned["disposition_codes"]
# THE CRITICAL CHECK: the list inside the assigned value must be a *different*
# object from the original list. If it's the same object, SQLAlchemy's change
# detection won't work because the "old" value was mutated in-place.
assert assigned["disposition_codes"] is not original_list, (
"The assigned disposition_codes list is the same object as the original. "
"This means SQLAlchemy won't detect the change because the old value "
"was mutated in-place via list.append()."
)

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "1.19.1",
"version": "1.19.2",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS='--enable-source-maps' next dev --turbopack",

View file

@ -1,6 +1,7 @@
"use client";
import { ArrowLeft, Check, Clock, Download, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react';
import { format } from 'date-fns';
import { ArrowLeft, CalendarIcon, Check, Clock, Download, Pause, Pencil, Play, RefreshCw, X } from 'lucide-react';
import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
@ -16,7 +17,11 @@ import {
import type { CampaignResponse } from '@/client/types.gen';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { CampaignRuns } from '@/components/workflow-runs';
import { useAuth } from '@/lib/auth';
@ -43,6 +48,13 @@ export default function CampaignDetailPage() {
const [isExecutingAction, setIsExecutingAction] = useState(false);
const [isDownloadingReport, setIsDownloadingReport] = useState(false);
// Report date range state
const [reportStartDate, setReportStartDate] = useState<Date | undefined>(undefined);
const [reportStartTime, setReportStartTime] = useState('00:00');
const [reportEndDate, setReportEndDate] = useState<Date | undefined>(undefined);
const [reportEndTime, setReportEndTime] = useState('23:59');
const [isReportPopoverOpen, setIsReportPopoverOpen] = useState(false);
// Fetch campaign details
const fetchCampaign = useCallback(async () => {
if (!user) return;
@ -113,16 +125,33 @@ export default function CampaignDetailPage() {
}
};
// Build ISO datetime string from date + time
const buildDateTime = (date: Date | undefined, time: string): string | undefined => {
if (!date) return undefined;
const [hours, minutes] = time.split(':').map(Number);
const combined = new Date(date);
combined.setHours(hours, minutes, 0, 0);
return combined.toISOString();
};
// Handle download report
const handleDownloadReport = async () => {
if (!user) return;
setIsDownloadingReport(true);
setIsReportPopoverOpen(false);
try {
const accessToken = await getAccessToken();
const startDate = buildDateTime(reportStartDate, reportStartTime);
const endDate = buildDateTime(reportEndDate, reportEndTime);
const response = await downloadCampaignReportApiV1CampaignCampaignIdReportGet({
path: {
campaign_id: campaignId,
},
query: {
start_date: startDate,
end_date: endDate,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
@ -150,6 +179,13 @@ export default function CampaignDetailPage() {
}
};
const handleClearDateRange = () => {
setReportStartDate(undefined);
setReportStartTime('00:00');
setReportEndDate(undefined);
setReportEndTime('23:59');
};
// Handle start campaign
const handleStart = async () => {
if (!user) return;
@ -368,10 +404,85 @@ export default function CampaignDetailPage() {
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleDownloadReport} disabled={isDownloadingReport}>
<Download className="h-4 w-4 mr-2" />
Download Report
</Button>
<Popover open={isReportPopoverOpen} onOpenChange={setIsReportPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" disabled={isDownloadingReport}>
<Download className="h-4 w-4 mr-2" />
Download Report
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="end">
<div className="space-y-4">
<div className="text-sm font-medium">Filter by date range</div>
<div className="grid gap-3">
<div className="space-y-1.5">
<Label className="text-xs">From</Label>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-[140px] justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-3.5 w-3.5" />
{reportStartDate ? format(reportStartDate, 'MMM dd, yyyy') : 'Start date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={reportStartDate}
onSelect={setReportStartDate}
disabled={(date) => reportEndDate ? date > reportEndDate : false}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={reportStartTime}
onChange={(e) => setReportStartTime(e.target.value)}
className="w-[100px] h-8 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">To</Label>
<div className="flex gap-2">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-[140px] justify-start text-left font-normal">
<CalendarIcon className="mr-2 h-3.5 w-3.5" />
{reportEndDate ? format(reportEndDate, 'MMM dd, yyyy') : 'End date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={reportEndDate}
onSelect={setReportEndDate}
disabled={(date) => reportStartDate ? date < reportStartDate : false}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={reportEndTime}
onChange={(e) => setReportEndTime(e.target.value)}
className="w-[100px] h-8 text-xs"
/>
</div>
</div>
</div>
<Separator />
<div className="flex justify-between">
<Button variant="ghost" size="sm" onClick={handleClearDateRange}>
Clear
</Button>
<Button size="sm" onClick={handleDownloadReport} disabled={isDownloadingReport}>
<Download className="h-3.5 w-3.5 mr-1.5" />
{reportStartDate || reportEndDate ? 'Download Filtered' : 'Download All'}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{renderActionButton()}
</div>
</div>

View file

@ -43,6 +43,7 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,
@ -91,10 +92,14 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
};
}, [isCompleted, auth.isAuthenticated, workflowId, workflowRunId]);
const navigateToApiKeys = () => {
const navigateToCredits = () => {
router.push('/api-keys');
};
const navigateToModelConfig = () => {
router.push('/model-configurations');
};
const navigateToWorkflow = () => {
router.push(`/workflow/${workflowId}`)
}
@ -161,7 +166,9 @@ const BrowserCall = ({ workflowId, workflowRunId, initialContextVariables }: {
open={apiKeyModalOpen}
onOpenChange={setApiKeyModalOpen}
error={apiKeyError}
onNavigateToApiKeys={navigateToApiKeys}
errorCode={apiKeyErrorCode}
onNavigateToCredits={navigateToCredits}
onNavigateToModelConfig={navigateToModelConfig}
/>
<WorkflowConfigErrorDialog

View file

@ -7,23 +7,25 @@ interface ApiKeyErrorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
error: string | null;
onNavigateToApiKeys: () => void;
errorCode: string | null;
onNavigateToCredits: () => void;
onNavigateToModelConfig: () => void;
}
export const ApiKeyErrorDialog = ({
open,
onOpenChange,
error,
onNavigateToApiKeys
errorCode,
onNavigateToCredits,
onNavigateToModelConfig,
}: ApiKeyErrorDialogProps) => {
// Check if this is a quota error based on the error message
const isQuotaError = error?.toLowerCase().includes('insufficient') ||
error?.toLowerCase().includes('credits') ||
error?.toLowerCase().includes('quota');
const isQuotaError = errorCode === 'quota_exceeded';
const title = isQuotaError ? "Insufficient Credits" : "API Configuration Error";
const icon = isQuotaError ? <CreditCard className="h-5 w-5 text-orange-500" /> : <Key className="h-5 w-5 text-red-500" />;
const buttonText = isQuotaError ? "Add Credits" : "Go to API Keys Settings";
const buttonText = isQuotaError ? "Add Credits" : "Go to Model Configurations";
const onNavigate = isQuotaError ? onNavigateToCredits : onNavigateToModelConfig;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -51,7 +53,7 @@ export const ApiKeyErrorDialog = ({
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={onNavigateToApiKeys}>
<Button onClick={onNavigate}>
{buttonText}
</Button>
</DialogFooter>

View file

@ -42,6 +42,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const [isCompleted, setIsCompleted] = useState(false);
const [apiKeyModalOpen, setApiKeyModalOpen] = useState(false);
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const [apiKeyErrorCode, setApiKeyErrorCode] = useState<string | null>(null);
const [workflowConfigModalOpen, setWorkflowConfigModalOpen] = useState(false);
const [workflowConfigError, setWorkflowConfigError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
@ -264,12 +265,15 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
break;
case 'error':
// Check if this is a quota exceeded error
if (message.payload?.error_type === 'quota_exceeded') {
// Check if this is a quota/service key error
if (message.payload?.error_type === 'quota_exceeded' ||
message.payload?.error_type === 'invalid_service_key' ||
message.payload?.error_type === 'quota_check_failed') {
// Log as info since it's a handled business logic case
logger.info('Quota exceeded, showing user dialog:', message.payload.message);
logger.info('Quota/service key error, showing user dialog:', message.payload.message);
// Set error state for display
setApiKeyErrorCode(message.payload.error_type);
setApiKeyError(message.payload.message || 'Service quota exceeded');
setApiKeyModalOpen(true);
@ -545,6 +549,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
if (response.error) {
setApiKeyModalOpen(true);
setApiKeyErrorCode('invalid_api_key');
let msg = 'API Key Error';
const detail = (response.error as unknown as { detail?: { errors: { model: string; message: string }[] } }).detail;
if (Array.isArray(detail)) {
@ -685,6 +690,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
apiKeyModalOpen,
setApiKeyModalOpen,
apiKeyError,
apiKeyErrorCode,
workflowConfigError,
workflowConfigModalOpen,
setWorkflowConfigModalOpen,

View file

@ -3274,7 +3274,16 @@ export type DownloadCampaignReportApiV1CampaignCampaignIdReportGetData = {
path: {
campaign_id: number;
};
query?: never;
query?: {
/**
* Filter runs created on or after this datetime (ISO 8601)
*/
start_date?: string | null;
/**
* Filter runs created on or before this datetime (ISO 8601)
*/
end_date?: string | null;
};
url: '/api/v1/campaign/{campaign_id}/report';
};

View file

@ -36,10 +36,26 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
}
}, [data, open]);
const handleSave = () => {
const handleSave = useCallback(() => {
onSave({ condition: condition, label: label, transition_speech: transitionSpeech || undefined });
onOpenChange(false);
};
}, [condition, label, transitionSpeech, onSave, onOpenChange]);
// Handle Cmd+S / Ctrl+S keyboard shortcut to save
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
e.stopImmediatePropagation();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [open, handleSave]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>