117 lines
3.9 KiB
Go
117 lines
3.9 KiB
Go
package callmemory
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"encoding/base64"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
type CallRecordsInput struct {
|
||
|
|
ElderID string `json:"elder_id"`
|
||
|
|
SessionID string `json:"business_session_id,omitempty"`
|
||
|
|
Cursor string `json:"cursor,omitempty"`
|
||
|
|
Limit int `json:"limit,omitempty"`
|
||
|
|
}
|
||
|
|
type recordCursor struct {
|
||
|
|
Time time.Time `json:"time"`
|
||
|
|
ID string `json:"id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) callRecords(ctx context.Context, tx pgx.Tx, p identity.Principal, op string, raw []byte) (any, error) {
|
||
|
|
var in CallRecordsInput
|
||
|
|
if e := StrictJSON(raw, &in); e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
if in.Limit == 0 {
|
||
|
|
in.Limit = 20
|
||
|
|
}
|
||
|
|
if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 {
|
||
|
|
return nil, fault(400, "INVALID_PAGINATION")
|
||
|
|
}
|
||
|
|
a, e := ResolveAccess(ctx, tx, p, in.ElderID, false)
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
// Transcripts have not been classified per sentence. A general read grant
|
||
|
|
// alone must not expose potentially sensitive conversation text.
|
||
|
|
if !a.Sensitive {
|
||
|
|
return nil, fault(403, "TRANSCRIPT_ACCESS_DENIED")
|
||
|
|
}
|
||
|
|
c := recordCursor{}
|
||
|
|
if in.Cursor != "" {
|
||
|
|
b, err := base64.RawURLEncoding.DecodeString(in.Cursor)
|
||
|
|
if err != nil || StrictJSON(b, &c) != nil || c.Time.IsZero() || c.ID == "" {
|
||
|
|
return nil, fault(400, "INVALID_CURSOR")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
var rows pgx.Rows
|
||
|
|
if op == "calls/query" {
|
||
|
|
if in.SessionID != "" || (c.ID != "" && !validUUID(c.ID)) {
|
||
|
|
return nil, fault(400, "INVALID_CALL_ID")
|
||
|
|
}
|
||
|
|
rows, e = tx.Query(ctx, `SELECT id::text,created_at,ended_at,call_state,transcript_state,
|
||
|
|
GREATEST(0,COALESCE(EXTRACT(EPOCH FROM (ended_at-created_at))*1000,0))::bigint
|
||
|
|
FROM call_session WHERE subject_id=$1::uuid AND tenant_id=$2::uuid
|
||
|
|
AND ($3='' OR (created_at,id)<($4,NULLIF($3,'')::uuid))
|
||
|
|
ORDER BY created_at DESC,id DESC LIMIT $5`, a.Subject, a.Tenant, c.ID, c.Time, in.Limit+1)
|
||
|
|
} else {
|
||
|
|
if !validUUID(in.SessionID) {
|
||
|
|
return nil, fault(400, "INVALID_CALL_ID")
|
||
|
|
}
|
||
|
|
var exists bool
|
||
|
|
e = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM call_session WHERE id=$1::uuid AND subject_id=$2::uuid AND tenant_id=$3::uuid)`, in.SessionID, a.Subject, a.Tenant).Scan(&exists)
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
if !exists {
|
||
|
|
return nil, fault(404, "CALL_NOT_FOUND")
|
||
|
|
}
|
||
|
|
rows, e = tx.Query(ctx, `SELECT u.utterance_id,u.source_at,u.speaker,u.text_content,u.interrupted,u.playback_complete
|
||
|
|
FROM call_utterance u JOIN call_session c ON c.id=u.session_id
|
||
|
|
WHERE c.id=$1::uuid AND c.subject_id=$2::uuid AND c.tenant_id=$3::uuid
|
||
|
|
AND NOT EXISTS(SELECT 1 FROM privacy_request pr WHERE pr.subject_id=c.subject_id AND pr.request_type='DELETE')
|
||
|
|
AND ($4='' OR (u.source_at,u.utterance_id)>($5,$4))
|
||
|
|
ORDER BY u.source_at,u.utterance_id LIMIT $6`, in.SessionID, a.Subject, a.Tenant, c.ID, c.Time, in.Limit+1)
|
||
|
|
}
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []map[string]any{}
|
||
|
|
next := ""
|
||
|
|
last := recordCursor{}
|
||
|
|
for rows.Next() {
|
||
|
|
if len(items) == in.Limit {
|
||
|
|
next = base64.RawURLEncoding.EncodeToString(jsonBytes(last))
|
||
|
|
break
|
||
|
|
}
|
||
|
|
var id string
|
||
|
|
var at time.Time
|
||
|
|
item := map[string]any{}
|
||
|
|
if op == "calls/query" {
|
||
|
|
var ended *time.Time
|
||
|
|
var state, transcript string
|
||
|
|
var duration int64
|
||
|
|
e = rows.Scan(&id, &at, &ended, &state, &transcript, &duration)
|
||
|
|
item = map[string]any{"id": id, "created_at": at, "ended_at": ended, "status": state, "transcript_state": transcript, "duration_ms": duration, "source": "call_memory"}
|
||
|
|
} else {
|
||
|
|
var speaker, content string
|
||
|
|
var interrupted, complete bool
|
||
|
|
e = rows.Scan(&id, &at, &speaker, &content, &interrupted, &complete)
|
||
|
|
item = map[string]any{"id": id, "created_at": at, "speaker": speaker, "final_text": content, "interrupted": interrupted, "playback_complete": complete}
|
||
|
|
}
|
||
|
|
if e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
last = recordCursor{at, id}
|
||
|
|
items = append(items, item)
|
||
|
|
}
|
||
|
|
if e = rows.Err(); e != nil {
|
||
|
|
return nil, e
|
||
|
|
}
|
||
|
|
return map[string]any{"items": items, "next_cursor": next}, nil
|
||
|
|
}
|