package saas import ( "context" "errors" "time" "data-terminal-cloud/internal/callmemory" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5" ) type HomeOverviewDay struct { Date string `json:"date"` ConversationCount int `json:"conversation_count"` DurationMS int64 `json:"duration_ms"` } type HomeOverviewConversation struct { ID string `json:"id"` Source string `json:"source"` Status string `json:"status"` StartedAt time.Time `json:"started_at"` EndedAt time.Time `json:"ended_at"` DurationMS int64 `json:"duration_ms"` Summary string `json:"summary,omitempty"` SummaryState string `json:"summary_state"` SummaryKind string `json:"summary_kind,omitempty"` } type HomeOverview struct { SubjectID string `json:"subject_id"` Timezone string `json:"timezone"` WeekStart string `json:"week_start"` WeekEnd string `json:"week_end"` ConversationCount int `json:"conversation_count"` TotalDurationMS int64 `json:"total_duration_ms"` Daily []HomeOverviewDay `json:"daily"` LatestConversation *HomeOverviewConversation `json:"latest_conversation"` } // These are separate session stores, not joins against turns or memories: a // session contributes once regardless of how many utterances or memories it has. // DIRECT has no reliable connected_at. Its existing created_at -> ended_at // duration is retained, but a finalized USER/ASSISTANT utterance must establish // actual interaction so failed preparations and abandoned leases do not count. const homeConversationsSQL = `WITH eligible AS ( SELECT c.public_id AS id,c.id AS internal_id,'conversation'::text AS source,c.status,c.started_at,c.ended_at FROM conversation c WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid AND c.status IN ('COMPLETED','FAILED','EXPIRED') AND c.started_at IS NOT NULL AND c.ended_at>c.started_at AND c.ended_at<=$3 AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=c.subject_id AND ss.scope='SOURCE_AND_DERIVED' AND (ss.source_refs @> jsonb_build_array(jsonb_build_object('conversation_id',c.public_id::text)) OR ss.source_refs @> jsonb_build_array(jsonb_build_object('conversation_id',c.id::text)) OR EXISTS(SELECT 1 FROM story_draft sd WHERE sd.conversation_id=c.id AND sd.subject_id=c.subject_id AND ss.source_refs @> jsonb_build_array(jsonb_build_object('story_id',sd.public_id::text))))) UNION ALL SELECT c.id,c.id,'call_memory',c.call_state,c.created_at,c.ended_at FROM call_session c WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid AND c.budget_snapshot->>'protocol'='DIRECT-CHAT-1' AND c.call_state IN ('ENDED','FAILED','CANCELLED') AND c.ended_at>c.created_at AND c.ended_at<=$3 AND EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker IN ('USER','ASSISTANT') AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]')) AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=c.subject_id AND ss.scope='SOURCE_AND_DERIVED' AND ss.source_refs @> jsonb_build_array(jsonb_build_object('session_id',c.id::text))) ), latest AS (SELECT source,id FROM eligible ORDER BY started_at DESC,id DESC,source DESC LIMIT 1) SELECT e.id::text,e.internal_id::text,e.source,e.status,e.started_at,e.ended_at FROM eligible e WHERE (e.started_at >= $4 AND e.started_at < $5) OR EXISTS(SELECT 1 FROM latest l WHERE l.source=e.source AND l.id=e.id) ORDER BY e.started_at DESC,e.id DESC,e.source DESC` // Home calendars use the product timezone even for historical profiles that // store another timezone. This changes presentation boundaries, not the profile. func homeCalendarWeek(now time.Time) (time.Time, time.Time, error) { location, err := time.LoadLocation("Asia/Shanghai") if err != nil { return time.Time{}, time.Time{}, err } local := now.In(location) start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location) start = start.AddDate(0, 0, -(int(start.Weekday())+6)%7) return start, start.AddDate(0, 0, 7), nil } func (s *Service) SubjectHomeOverview(ctx context.Context, principal identity.Principal, subjectPublicID string) (HomeOverview, error) { return s.subjectHomeOverviewAt(ctx, principal, subjectPublicID, time.Now()) } func (s *Service) subjectHomeOverviewAt(ctx context.Context, principal identity.Principal, subjectPublicID string, now time.Time) (HomeOverview, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) if err != nil { return HomeOverview{}, err } defer tx.Rollback(context.Background()) a, subject, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilityConversationRead, false) if err != nil { return HomeOverview{}, err } start, end, err := homeCalendarWeek(now) if err != nil { return HomeOverview{}, err } location := start.Location() result := HomeOverview{SubjectID: subject.ID, Timezone: location.String(), WeekStart: start.Format("2006-01-02"), WeekEnd: end.Format("2006-01-02"), Daily: make([]HomeOverviewDay, 7)} dayIndexes := map[string]int{} for i := range result.Daily { day := start.AddDate(0, 0, i).Format("2006-01-02") result.Daily[i].Date = day dayIndexes[day] = i } rows, err := tx.Query(ctx, homeConversationsSQL, a.tenantID, a.subjectID, now, start, end) if err != nil { return HomeOverview{}, err } var latestInternalID string for rows.Next() { var item HomeOverviewConversation var internalID string if err = rows.Scan(&item.ID, &internalID, &item.Source, &item.Status, &item.StartedAt, &item.EndedAt); err != nil { rows.Close() return HomeOverview{}, err } item.DurationMS = item.EndedAt.Sub(item.StartedAt).Milliseconds() item.SummaryState = "UNAVAILABLE" if result.LatestConversation == nil { result.LatestConversation = &item latestInternalID = internalID } // Entire duration belongs to the local start date, including midnight // crossings. AddDate above preserves calendar weeks through DST. if i, ok := dayIndexes[item.StartedAt.In(location).Format("2006-01-02")]; ok { result.Daily[i].ConversationCount++ result.Daily[i].DurationMS += item.DurationMS result.ConversationCount++ result.TotalDurationMS += item.DurationMS } } err = rows.Err() rows.Close() if err != nil { return HomeOverview{}, err } if result.LatestConversation != nil { grant, grantErr := callmemory.ResolveAccess(ctx, tx, principal, subjectPublicID, false) if grantErr == nil { err = s.homeConversationSummary(ctx, tx, a.subjectID, latestInternalID, grant, result.LatestConversation) if err != nil { return HomeOverview{}, err } } else { var denied *callmemory.Error if !errors.As(grantErr, &denied) || denied.Status != 403 { return HomeOverview{}, grantErr } } } return result, tx.Commit(ctx) } func (s *Service) homeConversationSummary(ctx context.Context, tx pgx.Tx, subject, id string, grant callmemory.Access, item *HomeOverviewConversation) error { if item.Source == "call_memory" { var allowed, hasUser, hasAssistant bool var transcript, state, lastError string err := tx.QueryRow(ctx, `SELECT c.memory_mode<>'OFF' AND managed_call_memory_authorized(c.subject_id) AND c.privacy_epoch=COALESCE(mus.privacy_epoch,0),c.transcript_state,COALESCE(j.state,''),COALESCE(j.last_error,''), EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker='USER' AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]')), EXISTS(SELECT 1 FROM call_utterance u WHERE u.session_id=c.id AND u.speaker='ASSISTANT' AND regexp_replace(u.text_content,'[[:space:]]','','g') NOT IN ('','[deleted]')) FROM call_session c LEFT JOIN memory_user_state mus ON mus.subject_id=c.subject_id LEFT JOIN call_memory_sync j ON j.session_id=c.id AND j.transcript_revision=c.transcript_revision WHERE c.id=$1::uuid AND c.subject_id=$2::uuid`, id, subject).Scan(&allowed, &transcript, &state, &lastError, &hasUser, &hasAssistant) if err != nil { return err } if !allowed { return nil } // A current, active, normal memory from this exact session is a short // memory recap, not a synthesized whole-conversation summary. err = tx.QueryRow(ctx, `SELECT mv.content FROM managed_memory_origin o JOIN curated_memory cm ON cm.id=o.memory_id AND cm.subject_id=o.subject_id JOIN memory_version mv ON mv.id=cm.current_version_id AND mv.memory_id=cm.id WHERE o.subject_id=$1::uuid AND o.session_id=$2::uuid AND cm.status='ACTIVE' AND cm.sensitivity='normal' AND NOT EXISTS(SELECT 1 FROM memory_source_suppression ss WHERE ss.subject_id=cm.subject_id AND ss.source_refs @> mv.source_refs) ORDER BY cm.created_at DESC,cm.public_id DESC LIMIT 1`, subject, id).Scan(&item.Summary) if err == nil { item.SummaryState = "READY" item.SummaryKind = "memory" return nil } if !errors.Is(err, pgx.ErrNoRows) { return err } switch { case transcript == "DRAINING" || transcript == "COLLECTING": item.SummaryState = "PROCESSING" case !hasUser || !hasAssistant: item.SummaryState = "INSUFFICIENT_TRANSCRIPT" case lastError != "": item.SummaryState = "SYNC_FAILED" case state == "SYNCED" || state == "SKIPPED": item.SummaryState = "NO_SUMMARY" default: item.SummaryState = "PROCESSING" } return nil } // Legacy summaries have no sensitivity classification; only a reader with // sensitive access may see them. Metadata permission alone never exposes text. if !grant.Sensitive { return nil } var allowed, hasUser bool var jobState string err := tx.QueryRow(ctx, `SELECT c.retention_mode<>'none' AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.consent_type='transcript_storage' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())) AND EXISTS(SELECT 1 FROM consent_record cr WHERE cr.subject_id=c.subject_id AND cr.consent_type='story_extraction' AND cr.withdrawn_at IS NULL AND (cr.expires_at IS NULL OR cr.expires_at>now())), EXISTS(SELECT 1 FROM conversation_turn ct WHERE ct.conversation_id=c.id AND ct.speaker='subject' AND regexp_replace(ct.final_text,'[[:space:]]','','g') NOT IN ('','[deleted]')), COALESCE((SELECT o.status FROM outbox_event o WHERE o.aggregate_id=c.id AND o.event_type='conversation.content_extraction_requested' ORDER BY o.created_at DESC,o.id DESC LIMIT 1),'') FROM conversation c WHERE c.id=$1::uuid AND c.subject_id=$2::uuid`, id, subject).Scan(&allowed, &hasUser, &jobState) if err != nil { return err } if !allowed { return nil } var state string err = tx.QueryRow(ctx, `SELECT content,status FROM conversation_summary WHERE subject_id=$1::uuid AND conversation_id=$2::uuid AND summary_type='conversation_summary' AND status<>'SUPERSEDED' ORDER BY generation_version DESC LIMIT 1`, subject, id).Scan(&item.Summary, &state) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return err } if err == nil && state == "READY" { item.SummaryState = "READY" item.SummaryKind = "conversation_summary" return nil } item.Summary = "" switch { case !hasUser: item.SummaryState = "INSUFFICIENT_TRANSCRIPT" case state == "FAILED" || jobState == "dead": item.SummaryState = "SYNC_FAILED" case state == "PROCESSING" || jobState == "pending" || jobState == "processing": item.SummaryState = "PROCESSING" default: item.SummaryState = "NO_SUMMARY" } return nil }