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"` RuntimeType string `json:"runtime_type"` 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"` } 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"` } type ConversationDetail struct { ConversationSummary HouseholdID string `json:"household_id"` Snapshots ConversationSnapshotSummary `json:"snapshots"` RTC *RTCTaskSummary `json:"rtc,omitempty"` HardwareAgent *HardwareAgentTaskSummary `json:"hardware_agent,omitempty"` History []ConversationStateEntry `json:"history"` } 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, ` SELECT c.public_id::text,$1::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode, 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 var hardwareProvider, hardwareStatus, hardwareTransport *string var hardwareStartedAt, hardwareEndedAt, hardwareLastEventAt *time.Time var hardwareLastSequence *int64 err := s.pool.QueryRow(ctx, ` SELECT c.public_id::text,sp.public_id::text,d.public_id::text,c.status,c.runtime_type,c.engine_mode,c.retention_mode, 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, 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 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 LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id AND hs.tenant_id=c.tenant_id 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( &result.ID, &result.SubjectID, &result.DeviceID, &result.Status, &result.RuntimeType, &result.EngineMode, &result.RetentionMode, &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, &rtcProvider, &rtcStatus, &rtcLeaseExpiresAt, &rtcStartAttempts, &rtcStopAttempts, &rtcLastCheckedAt, &hardwareProvider, &hardwareStatus, &hardwareTransport, &hardwareStartedAt, &hardwareEndedAt, &hardwareLastEventAt, &hardwareLastSequence) 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 } } 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 } } 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 if err := row.Scan(&item.ID, &item.SubjectID, &item.DeviceID, &item.Status, &item.RuntimeType, &item.EngineMode, &item.RetentionMode, &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 }