2026-08-26 06:41:07 +08:00
package saas
import (
"context"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/pagination"
"github.com/jackc/pgx/v5"
)
type ConversationFilter struct {
Status string
EngineMode string
From * time . Time
To * time . Time
Limit int
Cursor string
}
type ConversationSummary struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
DeviceID string `json:"device_id"`
Status string `json:"status"`
2026-09-06 17:04:11 +08:00
RuntimeType string `json:"runtime_type"`
2026-08-26 06:41:07 +08:00
EngineMode string `json:"engine_mode"`
RetentionMode string `json:"retention_mode"`
AudioRetentionEnabled bool `json:"audio_retention_enabled"`
StartedAt * time . Time `json:"started_at,omitempty"`
EndedAt * time . Time `json:"ended_at,omitempty"`
DurationMS * int64 `json:"duration_ms,omitempty"`
StopReason * string `json:"stop_reason,omitempty"`
FailureCode * string `json:"failure_code,omitempty"`
CreatedAt time . Time `json:"created_at"`
UpdatedAt time . Time `json:"updated_at"`
}
type ConversationPage struct {
Items [] ConversationSummary `json:"items"`
NextCursor string `json:"next_cursor,omitempty"`
}
type ConversationStateEntry struct {
FromStatus * string `json:"from_status,omitempty"`
ToStatus string `json:"to_status"`
Reason * string `json:"reason,omitempty"`
Source string `json:"source"`
OccurredAt time . Time `json:"occurred_at"`
}
type ConversationSnapshotSummary struct {
ConsentSnapshotID string `json:"consent_snapshot_id"`
ConsentSnapshotVersion int64 `json:"consent_snapshot_version"`
ContextSnapshotID string `json:"context_snapshot_id"`
ProfileVersion int64 `json:"profile_version"`
PromptBundleVersion string `json:"prompt_bundle_version"`
ContextPreparationStatus string `json:"context_preparation_status"`
ContextPreparationFailureCode * string `json:"context_preparation_failure_code,omitempty"`
}
type RTCTaskSummary struct {
Provider string `json:"provider"`
Status string `json:"status"`
LeaseExpiresAt * time . Time `json:"lease_expires_at,omitempty"`
StartAttempts int `json:"start_attempts"`
StopAttempts int `json:"stop_attempts"`
LastCheckedAt * time . Time `json:"last_checked_at,omitempty"`
}
2026-09-06 17:04:11 +08:00
type HardwareAgentTaskSummary struct {
Provider string `json:"provider"`
Status string `json:"status"`
Transport string `json:"transport"`
ProviderStartedAt * time . Time `json:"provider_started_at,omitempty"`
ProviderEndedAt * time . Time `json:"provider_ended_at,omitempty"`
LastEventAt * time . Time `json:"last_event_at,omitempty"`
LastEventSequence int64 `json:"last_event_sequence"`
}
2026-08-26 06:41:07 +08:00
type ConversationDetail struct {
ConversationSummary
2026-09-06 17:04:11 +08:00
HouseholdID string `json:"household_id"`
Snapshots ConversationSnapshotSummary `json:"snapshots"`
RTC * RTCTaskSummary `json:"rtc,omitempty"`
HardwareAgent * HardwareAgentTaskSummary `json:"hardware_agent,omitempty"`
History [] ConversationStateEntry `json:"history"`
2026-08-26 06:41:07 +08:00
}
type ConversationUsageSummary struct {
ConversationID string `json:"conversation_id"`
CostMicros int64 `json:"cost_micros"`
Currency string `json:"currency"`
Lines [] billing . CostSummaryLine `json:"lines"`
}
func ( s * Service ) ListSubjectConversations ( ctx context . Context , principal identity . Principal , subjectPublicID string , filter ConversationFilter ) ( ConversationPage , error ) {
access , _ , err := resolveSubject ( ctx , s . pool , principal , subjectPublicID , identity . CapabilityConversationRead , false )
if err != nil {
return ConversationPage {}, err
}
if s . cursor == nil {
return ConversationPage {}, errors . New ( "pagination cursor codec is unavailable" )
}
var cursorTime * time . Time
var cursorID * string
if filter . Cursor != "" {
decoded , decodeErr := s . cursor . Decode ( filter . Cursor )
if decodeErr != nil {
return ConversationPage {}, pagination . ErrInvalidCursor
}
parsed , parseErr := time . Parse ( time . RFC3339Nano , decoded . SortKey )
if parseErr != nil {
return ConversationPage {}, pagination . ErrInvalidCursor
}
cursorTime = & parsed
cursorID = & decoded . ID
}
rows , err := s . pool . Query ( ctx , `
2026-09-06 17:04:11 +08:00
SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
2026-08-26 06:41:07 +08:00
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
c.stop_reason,c.failure_code,c.created_at,c.updated_at
FROM conversation c JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
WHERE c.tenant_id=$2::uuid AND c.subject_id=$3::uuid
AND (NULLIF($4,'') IS NULL OR c.status=NULLIF($4,''))
AND (NULLIF($5,'') IS NULL OR c.engine_mode=NULLIF($5,''))
AND ($6::timestamptz IS NULL OR c.created_at >= $6)
AND ($7::timestamptz IS NULL OR c.created_at < $7)
AND ($8::timestamptz IS NULL OR (c.created_at,c.public_id)<($8,$9::uuid))
ORDER BY c.created_at DESC,c.public_id DESC LIMIT $10` , subjectPublicID , access . tenantID , access . subjectID ,
filter . Status , filter . EngineMode , filter . From , filter . To , cursorTime , cursorID , filter . Limit + 1 )
if err != nil {
return ConversationPage {}, fmt . Errorf ( "list subject conversations: %w" , err )
}
defer rows . Close ()
items := make ([] ConversationSummary , 0 , filter . Limit + 1 )
for rows . Next () {
item , scanErr := scanConversationSummary ( rows )
if scanErr != nil {
return ConversationPage {}, scanErr
}
items = append ( items , item )
}
if err := rows . Err (); err != nil {
return ConversationPage {}, fmt . Errorf ( "iterate subject conversations: %w" , err )
}
page := ConversationPage { Items : items }
if len ( items ) > filter . Limit {
page . Items = items [: filter . Limit ]
last := page . Items [ len ( page . Items ) - 1 ]
page . NextCursor , err = s . cursor . Encode ( last . CreatedAt . UTC (). Format ( time . RFC3339Nano ), last . ID )
if err != nil {
return ConversationPage {}, fmt . Errorf ( "encode next conversation cursor: %w" , err )
}
}
return page , nil
}
func ( s * Service ) GetConversation ( ctx context . Context , principal identity . Principal , conversationPublicID string ) ( ConversationDetail , error ) {
var result ConversationDetail
var duration * int64
var rtcProvider , rtcStatus * string
var rtcLeaseExpiresAt , rtcLastCheckedAt * time . Time
var rtcStartAttempts , rtcStopAttempts * int
2026-09-06 17:04:11 +08:00
var hardwareProvider , hardwareStatus , hardwareTransport * string
var hardwareStartedAt , hardwareEndedAt , hardwareLastEventAt * time . Time
var hardwareLastSequence * int64
2026-08-26 06:41:07 +08:00
err := s . pool . QueryRow ( ctx , `
2026-09-06 17:04:11 +08:00
SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode,
2026-08-26 06:41:07 +08:00
c.started_at,c.ended_at,CASE WHEN c.started_at IS NOT NULL AND c.ended_at IS NOT NULL THEN (EXTRACT(EPOCH FROM (c.ended_at-c.started_at))*1000)::bigint END,
c.stop_reason,c.failure_code,c.created_at,c.updated_at,h.public_id::text,
cs.public_id::text,cs.snapshot_version,xs.public_id::text,xs.profile_version,xs.prompt_bundle_version,
xs.preparation_status,xs.preparation_failure_code,
2026-09-06 17:04:11 +08:00
r.provider,r.status,r.lease_expires_at,r.start_attempts,r.stop_attempts,r.last_checked_at,
hs.provider,hs.status,hs.transport,hs.provider_started_at,hs.provider_ended_at,hs.last_event_at,hs.last_event_sequence
2026-08-26 06:41:07 +08:00
FROM conversation c
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id AND h.status <> 'deleted'
JOIN subject_profile sp ON sp.id=c.subject_id AND sp.tenant_id=c.tenant_id AND sp.status IN ('active','suspended')
JOIN device d ON d.id=c.device_id AND d.tenant_id=c.tenant_id
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
JOIN context_snapshot xs ON xs.id=c.context_snapshot_id AND xs.tenant_id=c.tenant_id
LEFT JOIN rtc_task r ON r.conversation_id=c.id
2026-09-06 17:04:11 +08:00
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id
2026-08-26 06:41:07 +08:00
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3` , conversationPublicID , principal . Issuer , principal . Subject ). Scan (
2026-09-06 17:04:11 +08:00
& result . ID , & result . SubjectID , & result . DeviceID , & result . Status , & result . RuntimeType , & result . EngineMode , & result . RetentionMode ,
2026-08-26 06:41:07 +08:00
& result . StartedAt , & result . EndedAt , & duration , & result . StopReason , & result . FailureCode , & result . CreatedAt , & result . UpdatedAt , & result . HouseholdID ,
& result . Snapshots . ConsentSnapshotID , & result . Snapshots . ConsentSnapshotVersion , & result . Snapshots . ContextSnapshotID , & result . Snapshots . ProfileVersion , & result . Snapshots . PromptBundleVersion ,
& result . Snapshots . ContextPreparationStatus , & result . Snapshots . ContextPreparationFailureCode ,
2026-09-06 17:04:11 +08:00
& rtcProvider , & rtcStatus , & rtcLeaseExpiresAt , & rtcStartAttempts , & rtcStopAttempts , & rtcLastCheckedAt ,
& hardwareProvider , & hardwareStatus , & hardwareTransport , & hardwareStartedAt , & hardwareEndedAt , & hardwareLastEventAt , & hardwareLastSequence )
2026-08-26 06:41:07 +08:00
if errors . Is ( err , pgx . ErrNoRows ) {
return ConversationDetail {}, ErrAccessDenied
}
if err != nil {
return ConversationDetail {}, fmt . Errorf ( "get conversation detail: %w" , err )
}
result . DurationMS = duration
result . AudioRetentionEnabled = result . RetentionMode == "transcript_and_audio"
if rtcProvider != nil {
result . RTC = & RTCTaskSummary { Provider : * rtcProvider , LeaseExpiresAt : rtcLeaseExpiresAt , LastCheckedAt : rtcLastCheckedAt }
if rtcStatus != nil {
result . RTC . Status = * rtcStatus
}
if rtcStartAttempts != nil {
result . RTC . StartAttempts = * rtcStartAttempts
}
if rtcStopAttempts != nil {
result . RTC . StopAttempts = * rtcStopAttempts
}
}
2026-09-06 17:04:11 +08:00
if hardwareProvider != nil {
result . HardwareAgent = & HardwareAgentTaskSummary { Provider : * hardwareProvider , ProviderStartedAt : hardwareStartedAt ,
ProviderEndedAt : hardwareEndedAt , LastEventAt : hardwareLastEventAt }
if hardwareStatus != nil {
result . HardwareAgent . Status = * hardwareStatus
}
if hardwareTransport != nil {
result . HardwareAgent . Transport = * hardwareTransport
}
if hardwareLastSequence != nil {
result . HardwareAgent . LastEventSequence = * hardwareLastSequence
}
}
2026-08-26 06:41:07 +08:00
rows , err := s . pool . Query ( ctx , `
SELECT h.from_status,h.to_status,h.reason,h.source,h.occurred_at
FROM conversation_state_history h JOIN conversation c ON c.id=h.conversation_id
WHERE c.public_id=$1::uuid ORDER BY h.occurred_at,h.id` , conversationPublicID )
if err != nil {
return ConversationDetail {}, fmt . Errorf ( "list conversation history: %w" , err )
}
defer rows . Close ()
result . History = make ([] ConversationStateEntry , 0 )
for rows . Next () {
var item ConversationStateEntry
if err := rows . Scan ( & item . FromStatus , & item . ToStatus , & item . Reason , & item . Source , & item . OccurredAt ); err != nil {
return ConversationDetail {}, fmt . Errorf ( "scan conversation history: %w" , err )
}
result . History = append ( result . History , item )
}
if err := rows . Err (); err != nil {
return ConversationDetail {}, fmt . Errorf ( "iterate conversation history: %w" , err )
}
return result , nil
}
func ( s * Service ) GetConversationUsage ( ctx context . Context , principal identity . Principal , conversationPublicID string ) ( ConversationUsageSummary , error ) {
var conversationID string
err := s . pool . QueryRow ( ctx , `
SELECT c.id::text
FROM conversation c
JOIN household h ON h.id=c.household_id AND h.tenant_id=c.tenant_id AND h.status<>'deleted'
JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=h.tenant_id AND ua.status='active'
WHERE c.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3` , conversationPublicID , principal . Issuer , principal . Subject ). Scan ( & conversationID )
if errors . Is ( err , pgx . ErrNoRows ) {
return ConversationUsageSummary {}, ErrAccessDenied
}
if err != nil {
return ConversationUsageSummary {}, fmt . Errorf ( "authorize conversation usage: %w" , err )
}
rows , err := s . pool . Query ( ctx , `
SELECT cue.usage_category,cue.metric_code,SUM(cue.quantity)::text,cue.unit,SUM(ccl.cost_micros)::bigint,ccl.currency
FROM conversation_usage_event cue JOIN conversation_cost_line ccl ON ccl.usage_event_id=cue.id
WHERE cue.conversation_id=$1::uuid
GROUP BY cue.usage_category,cue.metric_code,cue.unit,ccl.currency
ORDER BY cue.usage_category,cue.metric_code,cue.unit,ccl.currency` , conversationID )
if err != nil {
return ConversationUsageSummary {}, fmt . Errorf ( "list conversation usage: %w" , err )
}
defer rows . Close ()
result := ConversationUsageSummary { ConversationID : conversationPublicID , Lines : [] billing . CostSummaryLine {}}
for rows . Next () {
var item billing . CostSummaryLine
if err = rows . Scan ( & item . UsageCategory , & item . MetricCode , & item . Quantity , & item . Unit , & item . CostMicros , & item . Currency ); err != nil {
return ConversationUsageSummary {}, fmt . Errorf ( "scan conversation usage: %w" , err )
}
if result . Currency != "" && result . Currency != item . Currency {
return ConversationUsageSummary {}, fmt . Errorf ( "conversation usage contains multiple currencies" )
}
result . Currency = item . Currency
result . CostMicros += item . CostMicros
result . Lines = append ( result . Lines , item )
}
if err = rows . Err (); err != nil {
return ConversationUsageSummary {}, fmt . Errorf ( "iterate conversation usage: %w" , err )
}
return result , nil
}
func scanConversationSummary ( row rowScanner ) ( ConversationSummary , error ) {
var item ConversationSummary
2026-09-06 17:04:11 +08:00
if err := row . Scan ( & item . ID , & item . SubjectID , & item . DeviceID , & item . Status , & item . RuntimeType , & item . EngineMode , & item . RetentionMode ,
2026-08-26 06:41:07 +08:00
& item . StartedAt , & item . EndedAt , & item . DurationMS , & item . StopReason , & item . FailureCode , & item . CreatedAt , & item . UpdatedAt ); err != nil {
return ConversationSummary {}, err
}
item . AudioRetentionEnabled = item . RetentionMode == "transcript_and_audio"
return item , nil
}