Files
story/internal/saas/service.go
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

899 lines
38 KiB
Go

package saas
import (
"context"
"data-terminal-cloud/internal/devicefleet"
"encoding/json"
"errors"
"fmt"
"time"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/concurrency"
"data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/elderagent"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/outbox"
"data-terminal-cloud/internal/pagination"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
var (
ErrAccessDenied = errors.New("resource access denied")
ErrNotFound = errors.New("resource not found")
ErrVersionConflict = errors.New("resource version conflict")
ErrEffectiveConsentExist = errors.New("an effective consent already exists")
ErrPolicyVersionMismatch = errors.New("consent policy version mismatch")
ErrDeviceClaimInvalid = errors.New("device claim is invalid")
ErrDeviceClaimUsed = errors.New("device claim was used for another binding")
ErrDeviceAlreadyBound = errors.New("device already has an active binding")
ErrDeviceBindingBlocked = errors.New("device binding change has active blockers")
ErrDeviceBindingStale = errors.New("device binding changed since impact review")
ErrSubjectDeleteConflict = errors.New("subject cannot be deleted while a conversation is active")
ErrDeleteConfirmation = errors.New("subject deletion confirmation does not match")
)
const (
ConsentTranscriptStorage = "transcript_storage"
ConsentRawAudioStorage = "raw_audio_storage"
ConsentStoryExtraction = "story_extraction"
ConsentPersonalMemory = "personalized_memory"
ConsentFamilySharing = "family_sharing"
)
var consentTypes = []string{
ConsentTranscriptStorage,
ConsentRawAudioStorage,
ConsentStoryExtraction,
ConsentPersonalMemory,
ConsentFamilySharing,
}
type Household struct {
ID string `json:"id"`
TenantID string `json:"tenant_id"`
Name string `json:"name"`
Status string `json:"status"`
Role identity.Role `json:"role"`
Capabilities []identity.Capability `json:"capabilities"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Subject struct {
VoiceAgent *elderagent.Info `json:"voice_agent,omitempty"`
ID string `json:"id"`
HouseholdID string `json:"household_id"`
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map[string]any `json:"profile_data"`
ConversationPreferences map[string]any `json:"conversation_preferences"`
Status string `json:"status"`
Version int64 `json:"version"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Consent struct {
ID string `json:"id"`
SubjectID string `json:"subject_id"`
Type string `json:"type"`
Scope map[string]any `json:"scope"`
PolicyVersion string `json:"policy_version"`
NoticeDigest string `json:"notice_digest"`
ActorUserID string `json:"actor_user_id"`
EvidenceRef string `json:"evidence_ref,omitempty"`
GrantedAt time.Time `json:"granted_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
WithdrawnAt *time.Time `json:"withdrawn_at,omitempty"`
Effective bool `json:"effective"`
Version int64 `json:"version"`
}
type CreateSubjectInput struct {
InitialConsents InitialConsentSelection `json:"initial_consents,omitempty"`
PreferredName string `json:"preferred_name"`
Language string `json:"language"`
Timezone string `json:"timezone"`
ProfileData map[string]any `json:"profile_data"`
ConversationPreferences map[string]any `json:"conversation_preferences"`
}
type PatchSubjectInput struct {
PreferredName *string `json:"preferred_name,omitempty"`
Language *string `json:"language,omitempty"`
Timezone *string `json:"timezone,omitempty"`
ProfileData *map[string]any `json:"profile_data,omitempty"`
ConversationPreferences *map[string]any `json:"conversation_preferences,omitempty"`
Status *string `json:"status,omitempty"`
}
type GrantConsentInput struct {
Type string `json:"type"`
Scope map[string]any `json:"scope"`
PolicyVersion string `json:"policy_version"`
NoticeDigest string `json:"notice_digest"`
EvidenceRef string `json:"evidence_ref,omitempty"`
GrantedAt time.Time `json:"granted_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type Service struct {
Fleet *devicefleet.Service
pool *pgxpool.Pool
audit *audit.Repository
redis *redis.Client
cursor *pagination.Codec
exportSigner exportURLSigner
exportDownloadTTL time.Duration
firmwareStore firmwareObjectStore
firmwareURLTTL time.Duration
providerMemory bool
voiceAgents *elderagent.Service
}
func (s *Service) ConfigureVoiceAgents(agents *elderagent.Service) { s.voiceAgents = agents }
func (s *Service) EnsureSubjectVoiceAgent(ctx context.Context, principal identity.Principal, publicID string) (elderagent.Info, error) {
if _, _, err := resolveSubject(ctx, s.pool, principal, publicID, identity.CapabilitySubjectWrite, false); err != nil {
return elderagent.Info{}, err
}
if s.voiceAgents == nil {
return elderagent.Info{}, errors.New("voice agents not configured")
}
return s.voiceAgents.Ensure(ctx, publicID)
}
func (s *Service) ConfigureProviderMemory(enabled bool) {
s.providerMemory = enabled
}
func NewService(pool *pgxpool.Pool, auditRepository *audit.Repository, redisClient *redis.Client, cursorCodec *pagination.Codec) *Service {
return &Service{pool: pool, audit: auditRepository, redis: redisClient, cursor: cursorCodec}
}
func IsConsentType(value string) bool {
for _, consentType := range consentTypes {
if value == consentType {
return true
}
}
return false
}
func (s *Service) ListHouseholds(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Household, error) {
rows, err := s.pool.Query(ctx, `
SELECT h.public_id::text, t.public_id::text, h.name, h.status, hm.role,
h.version, h.created_at, h.updated_at
FROM user_account ua
JOIN tenant t ON t.id=ua.tenant_id AND t.status='active'
JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=ua.tenant_id AND hm.status='active'
JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id
WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND ua.status='active'
AND h.status <> 'deleted'
AND (NULLIF($3,'') IS NULL OR t.public_id=NULLIF($3,'')::uuid)
ORDER BY h.updated_at DESC, h.public_id`, principal.Issuer, principal.Subject, tenantPublicID)
if err != nil {
return nil, fmt.Errorf("list households: %w", err)
}
defer rows.Close()
result := make([]Household, 0)
for rows.Next() {
var item Household
if err := rows.Scan(&item.ID, &item.TenantID, &item.Name, &item.Status, &item.Role,
&item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan household: %w", err)
}
item.Capabilities = identity.CapabilitiesForRole(item.Role)
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate households: %w", err)
}
return result, nil
}
func (s *Service) CreateHousehold(ctx context.Context, principal identity.Principal, tenantPublicID, name, requestID string) (Household, error) {
var result Household
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
var tenantID, userID, userPublicID string
if err := tx.QueryRow(ctx, `
SELECT t.id::text, ua.id::text, ua.public_id::text
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
WHERE t.public_id=$1::uuid AND t.status='active'
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
FOR SHARE OF t, ua`, tenantPublicID, principal.Issuer, principal.Subject,
).Scan(&tenantID, &userID, &userPublicID); errors.Is(err, pgx.ErrNoRows) {
return ErrAccessDenied
} else if err != nil {
return fmt.Errorf("resolve tenant account: %w", err)
}
if err := tx.QueryRow(ctx, `
INSERT INTO household (tenant_id, name, owner_user_id)
VALUES ($1::uuid, $2, $3::uuid)
RETURNING public_id::text, $4::text, name, status, version, created_at, updated_at`,
tenantID, name, userID, tenantPublicID,
).Scan(&result.ID, &result.TenantID, &result.Name, &result.Status,
&result.Version, &result.CreatedAt, &result.UpdatedAt); err != nil {
return fmt.Errorf("insert household: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO household_member (tenant_id, household_id, user_id, role)
SELECT $1::uuid, id, $2::uuid, 'owner' FROM household WHERE public_id=$3::uuid`,
tenantID, userID, result.ID); err != nil {
return fmt.Errorf("insert household owner membership: %w", err)
}
result.Role = identity.RoleOwner
result.Capabilities = identity.CapabilitiesForRole(result.Role)
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(tenantID), ActorType: "user", ActorPublicID: strPtr(userPublicID),
Action: "household.create", ResourceType: "household", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"version": result.Version},
})
})
return result, err
}
func (s *Service) GetHousehold(ctx context.Context, principal identity.Principal, householdPublicID string) (Household, error) {
var result Household
if err := s.pool.QueryRow(ctx, householdSelectSQL+` AND h.public_id=$3::uuid`,
principal.Issuer, principal.Subject, householdPublicID,
).Scan(&result.ID, &result.TenantID, &result.Name, &result.Status, &result.Role,
&result.Version, &result.CreatedAt, &result.UpdatedAt); errors.Is(err, pgx.ErrNoRows) {
return Household{}, ErrAccessDenied
} else if err != nil {
return Household{}, fmt.Errorf("get household: %w", err)
}
if !identity.Can(result.Role, identity.CapabilityHouseholdRead) {
return Household{}, ErrAccessDenied
}
result.Capabilities = identity.CapabilitiesForRole(result.Role)
return result, nil
}
func (s *Service) PatchHousehold(ctx context.Context, principal identity.Principal, householdPublicID, name string, expectedVersion int64, requestID string) (Household, error) {
var result Household
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilityHouseholdManage, true)
if err != nil {
return err
}
if err := concurrency.RequireVersion(expectedVersion, access.version); err != nil {
return ErrVersionConflict
}
if err := tx.QueryRow(ctx, `
UPDATE household SET name=$1, version=version+1, updated_at=now()
WHERE id=$2::uuid AND tenant_id=$3::uuid
RETURNING public_id::text, $4::text, name, status, version, created_at, updated_at`,
name, access.householdID, access.tenantID, access.tenantPublicID,
).Scan(&result.ID, &result.TenantID, &result.Name, &result.Status,
&result.Version, &result.CreatedAt, &result.UpdatedAt); err != nil {
return fmt.Errorf("update household: %w", err)
}
result.Role = access.role
result.Capabilities = identity.CapabilitiesForRole(result.Role)
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "household.update", ResourceType: "household", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"from_version": expectedVersion, "to_version": result.Version},
})
})
return result, err
}
func (s *Service) ListSubjects(ctx context.Context, principal identity.Principal, householdPublicID string) ([]Subject, error) {
access, err := resolveHouseholdAccess(ctx, s.pool, principal, householdPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT s.public_id::text, $1::text, s.preferred_name, s.language, s.timezone,
s.profile_data, s.conversation_preferences, s.status, s.profile_version, s.created_at, s.updated_at
FROM subject_profile s
WHERE s.tenant_id=$2::uuid AND s.household_id=$3::uuid AND s.status IN ('active','suspended')
ORDER BY s.updated_at DESC, s.public_id`, householdPublicID, access.tenantID, access.householdID)
if err != nil {
return nil, fmt.Errorf("list subjects: %w", err)
}
defer rows.Close()
result := make([]Subject, 0)
for rows.Next() {
item, err := scanSubject(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate subjects: %w", err)
}
rows.Close()
if s.voiceAgents != nil {
for n := range result {
info, e := s.voiceAgents.Get(ctx, result[n].ID)
if e != nil {
return nil, e
}
result[n].VoiceAgent = &info
}
}
return result, nil
}
func (s *Service) CreateSubject(ctx context.Context, principal identity.Principal, householdPublicID, requestID string, input CreateSubjectInput) (Subject, error) {
if err := ValidateInitialConsents(input.InitialConsents); err != nil {
return Subject{}, err
}
var result Subject
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, err := resolveHouseholdAccess(ctx, tx, principal, householdPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
profileJSON, _ := json.Marshal(input.ProfileData)
preferencesJSON, _ := json.Marshal(input.ConversationPreferences)
row := tx.QueryRow(ctx, `
INSERT INTO subject_profile (
tenant_id, household_id, preferred_name, language, timezone,
profile_data, conversation_preferences, created_by, updated_by
) VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6::jsonb, $7::jsonb, $8::uuid, $8::uuid)
RETURNING public_id::text, $9::text, preferred_name, language, timezone,
profile_data, conversation_preferences, status, profile_version, created_at, updated_at`,
access.tenantID, access.householdID, input.PreferredName, input.Language, input.Timezone,
profileJSON, preferencesJSON, access.userID, householdPublicID)
result, err = scanSubject(row)
if err != nil {
return fmt.Errorf("insert subject: %w", err)
}
if err := InitializeSubjectMemory(ctx, tx, access.tenantID, result.ID, access.userID, requestID, input.InitialConsents); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "subject.create", ResourceType: "subject", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"household_id": householdPublicID, "version": result.Version},
})
})
if err == nil && s.voiceAgents != nil {
info, provisionErr := s.voiceAgents.Ensure(ctx, result.ID)
if provisionErr != nil && info.Status == "" {
info = elderagent.Info{Status: "FAILED", Message: "老人已保存,智能体暂未就绪,请重试"}
}
result.VoiceAgent = &info
}
return result, err
}
func (s *Service) GetSubject(ctx context.Context, principal identity.Principal, subjectPublicID string) (Subject, error) {
access, result, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
_ = access
if err == nil && s.voiceAgents != nil {
info, e := s.voiceAgents.Get(ctx, subjectPublicID)
if e != nil {
return result, e
}
result.VoiceAgent = &info
}
return result, err
}
func (s *Service) PatchSubject(ctx context.Context, principal identity.Principal, subjectPublicID, requestID string, expectedVersion int64, input PatchSubjectInput) (Subject, error) {
var result Subject
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, current, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
if current.Version != expectedVersion {
return ErrVersionConflict
}
preferredName, language, timezone, status := current.PreferredName, current.Language, current.Timezone, current.Status
profile, preferences := current.ProfileData, current.ConversationPreferences
if input.PreferredName != nil {
preferredName = *input.PreferredName
}
if input.Language != nil {
language = *input.Language
}
if input.Timezone != nil {
timezone = *input.Timezone
}
if input.Status != nil {
status = *input.Status
}
if input.ProfileData != nil {
profile = *input.ProfileData
}
if input.ConversationPreferences != nil {
preferences = *input.ConversationPreferences
}
profileJSON, _ := json.Marshal(profile)
preferencesJSON, _ := json.Marshal(preferences)
row := tx.QueryRow(ctx, `
UPDATE subject_profile SET preferred_name=$1, language=$2, timezone=$3,
profile_data=$4::jsonb, conversation_preferences=$5::jsonb, status=$6,
updated_by=$7::uuid, profile_version=profile_version+1, updated_at=now()
WHERE id=$8::uuid AND tenant_id=$9::uuid
RETURNING public_id::text, $10::text, preferred_name, language, timezone,
profile_data, conversation_preferences, status, profile_version, created_at, updated_at`,
preferredName, language, timezone, profileJSON, preferencesJSON, status,
access.userID, access.subjectID, access.tenantID, access.householdPublicID)
result, err = scanSubject(row)
if err != nil {
return fmt.Errorf("update subject: %w", err)
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "subject.update", ResourceType: "subject", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"from_version": expectedVersion, "to_version": result.Version},
})
})
if err == nil && s.voiceAgents != nil {
// The database trigger queues this atomically with the profile change.
// A provider failure must not undo a successfully saved profile.
info, syncErr := s.voiceAgents.Sync(ctx, result.ID)
if syncErr != nil && info.Status == "" {
info = elderagent.Info{Status: "PENDING", PersonaSyncStatus: "PENDING", Message: "资料已保存,等待同步"}
}
result.VoiceAgent = &info
}
return result, err
}
func (s *Service) ListConsents(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]Consent, error) {
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil {
return nil, err
}
rows, err := s.pool.Query(ctx, `
SELECT c.public_id::text, $1::text, c.consent_type, c.scope, c.policy_version,
c.notice_digest, ua.public_id::text, COALESCE(c.evidence_ref,''), c.granted_at,
c.expires_at, c.withdrawn_at, c.version
FROM consent_record c JOIN user_account ua ON ua.id=c.actor_user_id AND ua.tenant_id=c.tenant_id
WHERE c.tenant_id=$2::uuid AND c.subject_id=$3::uuid
ORDER BY c.granted_at DESC, c.id DESC`, subjectPublicID, access.tenantID, access.subjectID)
if err != nil {
return nil, fmt.Errorf("list consents: %w", err)
}
defer rows.Close()
result := make([]Consent, 0)
for rows.Next() {
item, err := scanConsent(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate consents: %w", err)
}
return result, nil
}
func (s *Service) GrantConsent(ctx context.Context, principal identity.Principal, subjectPublicID, requestID string, input GrantConsentInput) (Consent, error) {
var result Consent
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, _, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
if input.PolicyVersion != access.policyVersion {
return ErrPolicyVersionMismatch
}
var existing bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(
SELECT 1 FROM consent_record WHERE tenant_id=$1::uuid AND subject_id=$2::uuid
AND consent_type=$3 AND withdrawn_at IS NULL AND (expires_at IS NULL OR expires_at > now())
)`, access.tenantID, access.subjectID, input.Type).Scan(&existing); err != nil {
return fmt.Errorf("check effective consent: %w", err)
}
if existing {
return ErrEffectiveConsentExist
}
scopeJSON, _ := json.Marshal(input.Scope)
row := tx.QueryRow(ctx, `
INSERT INTO consent_record (
tenant_id, subject_id, consent_type, scope, policy_version, notice_digest,
actor_user_id, evidence_ref, granted_at, expires_at
) VALUES ($1::uuid,$2::uuid,$3,$4::jsonb,$5,$6,$7::uuid,NULLIF($8,''),$9,$10)
RETURNING public_id::text, $11::text, consent_type, scope, policy_version,
notice_digest, $12::text, COALESCE(evidence_ref,''), granted_at, expires_at, withdrawn_at, version`,
access.tenantID, access.subjectID, input.Type, scopeJSON, input.PolicyVersion,
input.NoticeDigest, access.userID, input.EvidenceRef, input.GrantedAt, input.ExpiresAt,
subjectPublicID, access.userPublicID)
result, err = scanConsent(row)
if err != nil {
return fmt.Errorf("insert consent: %w", err)
}
if err := createConsentSnapshot(ctx, tx, access.tenantID, access.subjectID); err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "consent.grant", ResourceType: "consent", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{"subject_id": subjectPublicID, "consent_type": result.Type},
})
})
return result, err
}
func (s *Service) WithdrawConsent(ctx context.Context, principal identity.Principal, subjectPublicID, consentPublicID, requestID string) (Consent, error) {
var result Consent
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
access, _, err := resolveSubject(ctx, tx, principal, subjectPublicID, identity.CapabilitySubjectWrite, true)
if err != nil {
return err
}
row := tx.QueryRow(ctx, `
SELECT c.public_id::text, $1::text, c.consent_type, c.scope, c.policy_version,
c.notice_digest, ua.public_id::text, COALESCE(c.evidence_ref,''), c.granted_at,
c.expires_at, c.withdrawn_at, c.version
FROM consent_record c JOIN user_account ua ON ua.id=c.actor_user_id AND ua.tenant_id=c.tenant_id
WHERE c.public_id=$2::uuid AND c.tenant_id=$3::uuid AND c.subject_id=$4::uuid
FOR UPDATE OF c`, subjectPublicID, consentPublicID, access.tenantID, access.subjectID)
result, err = scanConsent(row)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("get consent for withdrawal: %w", err)
}
if result.WithdrawnAt != nil {
return nil
}
row = tx.QueryRow(ctx, `
UPDATE consent_record SET withdrawn_at=now(), version=version+1, updated_at=now()
WHERE public_id=$1::uuid
RETURNING public_id::text, $2::text, consent_type, scope, policy_version,
notice_digest, $3::text, COALESCE(evidence_ref,''), granted_at, expires_at, withdrawn_at, version`,
consentPublicID, subjectPublicID, result.ActorUserID)
result, err = scanConsent(row)
if err != nil {
return fmt.Errorf("withdraw consent: %w", err)
}
if err := createConsentSnapshot(ctx, tx, access.tenantID, access.subjectID); err != nil {
return err
}
if result.Type == "personalized_memory" {
if _, err := tx.Exec(ctx, `
UPDATE curated_memory SET status='DISABLED',version=version+1,updated_at=now()
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status='ACTIVE'`, access.tenantID, access.subjectID); err != nil {
return fmt.Errorf("disable memories after consent withdrawal: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE memory_index_entry SET embedding=NULL,status='STALE',last_error='personalized memory consent withdrawn',updated_at=now()
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status IN ('PENDING','ACTIVE')`, access.tenantID, access.subjectID); err != nil {
return fmt.Errorf("invalidate memory index after consent withdrawal: %w", err)
}
if s.providerMemory {
rows, err := tx.Query(ctx, `SELECT id::text FROM curated_memory WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND status<>'DELETED' ORDER BY id`, access.tenantID, access.subjectID)
if err != nil {
return fmt.Errorf("list provider memories after consent withdrawal: %w", err)
}
memoryIDs := make([]string, 0)
for rows.Next() {
var memoryID string
if err := rows.Scan(&memoryID); err != nil {
rows.Close()
return err
}
memoryIDs = append(memoryIDs, memoryID)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, memoryID := range memoryIDs {
dedupe := "memory-provider-delete-consent:" + memoryID + ":" + result.ID
payload, _ := json.Marshal(map[string]string{"reason": "personalized memory consent withdrawn", "consent_id": result.ID})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "curated_memory", AggregateID: memoryID, EventType: "memory.provider_delete_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
return err
}
}
}
}
affectedConversations, err := s.stopConversationsAfterConsentWithdrawal(ctx, tx, access.tenantID, access.subjectID, result.Type)
if err != nil {
return err
}
return s.audit.Record(ctx, tx, audit.Event{
TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID),
Action: "consent.withdraw", ResourceType: "consent", ResourceID: strPtr(result.ID),
RequestID: requestID, Result: "success", Detail: map[string]any{
"subject_id": subjectPublicID, "consent_type": result.Type,
"stopped_conversation_ids": affectedConversations,
},
})
})
return result, err
}
func consentWithdrawalStopsConversation(consentType string) bool {
switch consentType {
case ConsentTranscriptStorage, ConsentRawAudioStorage, ConsentStoryExtraction, ConsentPersonalMemory:
return true
default:
return false
}
}
func (s *Service) stopConversationsAfterConsentWithdrawal(
ctx context.Context,
tx pgx.Tx,
tenantID, subjectID, consentType string,
) ([]string, error) {
if !consentWithdrawalStopsConversation(consentType) {
return []string{}, nil
}
type candidate struct {
internalID, publicID, status, runtimeType string
}
rows, err := tx.Query(ctx, `
SELECT c.id::text,c.public_id::text,c.status,c.runtime_type
FROM conversation c
JOIN consent_snapshot cs ON cs.id=c.consent_snapshot_id AND cs.tenant_id=c.tenant_id
LEFT JOIN rtc_task rt ON rt.conversation_id=c.id
LEFT JOIN hardware_agent_session hs ON hs.conversation_id=c.id
WHERE c.tenant_id=$1::uuid AND c.subject_id=$2::uuid
AND c.status IN ('PREPARING','ROOM_ISSUED','STARTING_AGENT','ACTIVE')
AND cs.effective_consents @> jsonb_build_object($3::text,true)
ORDER BY c.created_at,c.id
FOR UPDATE OF c`, tenantID, subjectID, consentType)
if err != nil {
return nil, fmt.Errorf("find conversations affected by consent withdrawal: %w", err)
}
candidates := make([]candidate, 0)
for rows.Next() {
var item candidate
if err := rows.Scan(&item.internalID, &item.publicID, &item.status, &item.runtimeType); err != nil {
rows.Close()
return nil, fmt.Errorf("scan consent withdrawal conversation: %w", err)
}
candidates = append(candidates, item)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, fmt.Errorf("iterate consent withdrawal conversations: %w", err)
}
rows.Close()
affected := make([]string, 0, len(candidates))
stopReason := "consent_withdrawn:" + consentType
for _, item := range candidates {
toStatus := "EXPIRED"
if item.status == "STARTING_AGENT" || item.status == "ACTIVE" {
toStatus = "STOPPING"
}
tag, err := tx.Exec(ctx, `
UPDATE conversation SET status=$2::varchar,stop_reason=$3,ended_at=CASE WHEN $2::text='EXPIRED' THEN now() ELSE ended_at END,
lease_expires_at=NULL,version=version+1,updated_at=now()
WHERE id=$1::uuid AND status=$4::varchar`, item.internalID, toStatus, stopReason, item.status)
if err != nil {
return nil, fmt.Errorf("stop conversation after consent withdrawal: %w", err)
}
if tag.RowsAffected() != 1 {
return nil, fmt.Errorf("conversation changed during consent withdrawal")
}
if item.runtimeType == "volc_hardware_agent" && toStatus == "EXPIRED" {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='EXPIRED',updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
} else if item.runtimeType == "volc_hardware_agent" {
_, err = tx.Exec(ctx, `UPDATE hardware_agent_session SET status='STOPPING',updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
} else if toStatus == "EXPIRED" {
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
} else {
_, err = tx.Exec(ctx, `UPDATE rtc_task SET status=CASE WHEN status='ACTIVE' THEN 'STOPPING' ELSE 'UNKNOWN' END,lease_expires_at=NULL,updated_at=now() WHERE conversation_id=$1::uuid`, item.internalID)
}
if err != nil {
return nil, fmt.Errorf("update RTC after consent withdrawal: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source)
VALUES($1::uuid,$2,$3,$4,'platform')`, item.internalID, item.status, toStatus, "consent withdrawn: "+consentType); err != nil {
return nil, fmt.Errorf("record consent withdrawal stop: %w", err)
}
if toStatus == "STOPPING" && item.runtimeType != "volc_hardware_agent" {
deduplicationKey := "consent-withdrawal-stop:" + item.internalID + ":" + consentType
payload, _ := json.Marshal(map[string]string{
"conversation_id": item.publicID,
"reason": stopReason,
})
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{
TenantID: strPtr(tenantID), AggregateType: "conversation", AggregateID: item.internalID,
EventType: "conversation.agent_stop_requested", DeduplicationKey: &deduplicationKey, Payload: payload,
}); err != nil {
return nil, fmt.Errorf("enqueue consent withdrawal stop: %w", err)
}
}
affected = append(affected, item.publicID)
}
return affected, nil
}
const householdSelectSQL = `
SELECT h.public_id::text, t.public_id::text, h.name, h.status, hm.role,
h.version, h.created_at, h.updated_at
FROM household h
JOIN tenant t ON t.id=h.tenant_id AND t.status='active'
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 ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND h.status <> 'deleted'`
type queryRow interface {
QueryRow(context.Context, string, ...any) pgx.Row
}
type resourceAccess struct {
tenantID, tenantPublicID, userID, userPublicID string
householdID, householdPublicID, subjectID string
policyVersion string
role identity.Role
version int64
}
func resolveHouseholdAccess(ctx context.Context, q queryRow, principal identity.Principal, publicID string, capability identity.Capability, lock bool) (resourceAccess, error) {
sql := `
SELECT t.id::text,t.public_id::text,t.policy_version,ua.id::text,ua.public_id::text,
h.id::text,h.public_id::text,hm.role,h.version
FROM household h
JOIN tenant t ON t.id=h.tenant_id AND t.status='active'
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 h.public_id=$1::uuid AND h.status <> 'deleted'
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF h`
}
var a resourceAccess
var role string
err := q.QueryRow(ctx, sql, publicID, principal.Issuer, principal.Subject).Scan(
&a.tenantID, &a.tenantPublicID, &a.policyVersion, &a.userID, &a.userPublicID,
&a.householdID, &a.householdPublicID, &role, &a.version)
if errors.Is(err, pgx.ErrNoRows) {
return resourceAccess{}, ErrAccessDenied
}
if err != nil {
return resourceAccess{}, fmt.Errorf("resolve household access: %w", err)
}
a.role = identity.Role(role)
if !identity.Can(a.role, capability) {
return resourceAccess{}, ErrAccessDenied
}
return a, nil
}
func resolveSubject(ctx context.Context, q queryRow, principal identity.Principal, publicID string, capability identity.Capability, lock bool) (resourceAccess, Subject, error) {
sql := `
SELECT t.id::text,t.public_id::text,t.policy_version,ua.id::text,ua.public_id::text,
h.id::text,h.public_id::text,s.id::text,hm.role,
s.public_id::text,h.public_id::text,s.preferred_name,s.language,s.timezone,
s.profile_data,s.conversation_preferences,s.status,s.profile_version,s.created_at,s.updated_at
FROM subject_profile s
JOIN household h ON h.id=s.household_id AND h.tenant_id=s.tenant_id AND h.status <> 'deleted'
JOIN tenant t ON t.id=s.tenant_id AND t.status='active'
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 s.public_id=$1::uuid AND s.status IN ('active','suspended')
AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`
if lock {
sql += ` FOR UPDATE OF s`
}
var a resourceAccess
var role string
var result Subject
var profileJSON, preferencesJSON []byte
err := q.QueryRow(ctx, sql, publicID, principal.Issuer, principal.Subject).Scan(
&a.tenantID, &a.tenantPublicID, &a.policyVersion, &a.userID, &a.userPublicID,
&a.householdID, &a.householdPublicID, &a.subjectID, &role,
&result.ID, &result.HouseholdID, &result.PreferredName, &result.Language, &result.Timezone,
&profileJSON, &preferencesJSON, &result.Status, &result.Version, &result.CreatedAt, &result.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return resourceAccess{}, Subject{}, ErrAccessDenied
}
if err != nil {
return resourceAccess{}, Subject{}, fmt.Errorf("resolve subject: %w", err)
}
a.role = identity.Role(role)
if !identity.Can(a.role, capability) {
return resourceAccess{}, Subject{}, ErrAccessDenied
}
if err := decodeObject(profileJSON, &result.ProfileData); err != nil {
return resourceAccess{}, Subject{}, err
}
if err := decodeObject(preferencesJSON, &result.ConversationPreferences); err != nil {
return resourceAccess{}, Subject{}, err
}
return a, result, nil
}
type rowScanner interface{ Scan(...any) error }
func scanSubject(row rowScanner) (Subject, error) {
var item Subject
var profileJSON, preferencesJSON []byte
if err := row.Scan(&item.ID, &item.HouseholdID, &item.PreferredName, &item.Language, &item.Timezone,
&profileJSON, &preferencesJSON, &item.Status, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
return Subject{}, err
}
if err := decodeObject(profileJSON, &item.ProfileData); err != nil {
return Subject{}, err
}
if err := decodeObject(preferencesJSON, &item.ConversationPreferences); err != nil {
return Subject{}, err
}
return item, nil
}
func scanConsent(row rowScanner) (Consent, error) {
var item Consent
var scopeJSON []byte
if err := row.Scan(&item.ID, &item.SubjectID, &item.Type, &scopeJSON, &item.PolicyVersion,
&item.NoticeDigest, &item.ActorUserID, &item.EvidenceRef, &item.GrantedAt,
&item.ExpiresAt, &item.WithdrawnAt, &item.Version); err != nil {
return Consent{}, err
}
if err := decodeObject(scopeJSON, &item.Scope); err != nil {
return Consent{}, err
}
item.Effective = item.WithdrawnAt == nil && (item.ExpiresAt == nil || item.ExpiresAt.After(time.Now()))
return item, nil
}
func createConsentSnapshot(ctx context.Context, tx pgx.Tx, tenantID, subjectID string) error {
effective := map[string]bool{"voice_chat": true}
for _, consentType := range consentTypes {
effective[consentType] = false
}
rows, err := tx.Query(ctx, `
SELECT public_id::text, consent_type FROM consent_record
WHERE tenant_id=$1::uuid AND subject_id=$2::uuid AND withdrawn_at IS NULL
AND (expires_at IS NULL OR expires_at > now())
ORDER BY granted_at,id`, tenantID, subjectID)
if err != nil {
return fmt.Errorf("query effective consents: %w", err)
}
defer rows.Close()
sourceIDs := make([]string, 0)
for rows.Next() {
var publicID, consentType string
if err := rows.Scan(&publicID, &consentType); err != nil {
return fmt.Errorf("scan effective consent: %w", err)
}
effective[consentType] = true
sourceIDs = append(sourceIDs, publicID)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterate effective consents: %w", err)
}
effectiveJSON, _ := json.Marshal(effective)
sourceJSON, _ := json.Marshal(sourceIDs)
if _, err := tx.Exec(ctx, `
INSERT INTO consent_snapshot (tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids)
SELECT $1::uuid,$2::uuid,COALESCE(MAX(snapshot_version),0)+1,$3::jsonb,$4::jsonb
FROM consent_snapshot WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
tenantID, subjectID, effectiveJSON, sourceJSON); err != nil {
return fmt.Errorf("insert consent snapshot: %w", err)
}
return nil
}
func decodeObject(value []byte, target *map[string]any) error {
if len(value) == 0 {
*target = map[string]any{}
return nil
}
if err := json.Unmarshal(value, target); err != nil {
return fmt.Errorf("decode JSON object: %w", err)
}
if *target == nil {
*target = map[string]any{}
}
return nil
}
func strPtr(value string) *string { return &value }