package saas import ( "context" "errors" "fmt" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5" ) type SubjectFact struct { ID string `json:"id"` SubjectID string `json:"subject_id"` FactType string `json:"fact_type"` Content string `json:"content"` SourceType string `json:"source_type"` SourceRef string `json:"source_ref,omitempty"` ConfirmationStatus string `json:"confirmation_status"` Sensitivity string `json:"sensitivity"` ValidFrom *time.Time `json:"valid_from,omitempty"` ValidTo *time.Time `json:"valid_to,omitempty"` Version int64 `json:"version"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type CreateFactInput struct { FactType string `json:"fact_type"` Content string `json:"content"` SourceType string `json:"source_type"` SourceRef string `json:"source_ref,omitempty"` ConfirmationStatus string `json:"confirmation_status"` Sensitivity string `json:"sensitivity"` ValidFrom *time.Time `json:"valid_from,omitempty"` ValidTo *time.Time `json:"valid_to,omitempty"` } type PatchFactInput struct { FactType *string `json:"fact_type,omitempty"` Content *string `json:"content,omitempty"` SourceRef *string `json:"source_ref,omitempty"` ConfirmationStatus *string `json:"confirmation_status,omitempty"` Sensitivity *string `json:"sensitivity,omitempty"` ValidFrom *time.Time `json:"valid_from,omitempty"` ValidTo *time.Time `json:"valid_to,omitempty"` } func (s *Service) ListSubjectFacts(ctx context.Context, principal identity.Principal, subjectPublicID string) ([]SubjectFact, 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 f.public_id::text,$1::text,f.fact_type,f.content,f.source_type,COALESCE(f.source_ref,''), f.confirmation_status,f.sensitivity,f.valid_from,f.valid_to,f.version,f.created_at,f.updated_at FROM subject_fact f WHERE f.tenant_id=$2::uuid AND f.subject_id=$3::uuid ORDER BY f.updated_at DESC,f.public_id`, subjectPublicID, access.tenantID, access.subjectID) if err != nil { return nil, fmt.Errorf("list subject facts: %w", err) } defer rows.Close() result := make([]SubjectFact, 0) for rows.Next() { item, scanErr := scanFact(rows) if scanErr != nil { return nil, scanErr } result = append(result, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate subject facts: %w", err) } return result, nil } func (s *Service) CreateSubjectFact(ctx context.Context, principal identity.Principal, subjectPublicID, requestID string, input CreateFactInput) (SubjectFact, error) { var result SubjectFact 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, ` INSERT INTO subject_fact(tenant_id,subject_id,fact_type,content,source_type,source_ref, confirmation_status,sensitivity,valid_from,valid_to,created_by,updated_by) VALUES($1::uuid,$2::uuid,$3,$4,$5,NULLIF($6,''),$7,$8,$9,$10,$11::uuid,$11::uuid) RETURNING public_id::text,$12::text,fact_type,content,source_type,COALESCE(source_ref,''), confirmation_status,sensitivity,valid_from,valid_to,version,created_at,updated_at`, access.tenantID, access.subjectID, input.FactType, input.Content, input.SourceType, input.SourceRef, input.ConfirmationStatus, input.Sensitivity, input.ValidFrom, input.ValidTo, access.userID, subjectPublicID) result, err = scanFact(row) if err != nil { return fmt.Errorf("insert subject fact: %w", err) } return s.audit.Record(ctx, tx, audit.Event{ TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "subject_fact.create", ResourceType: "subject_fact", ResourceID: strPtr(result.ID), RequestID: requestID, Result: "success", Detail: map[string]any{"subject_id": subjectPublicID, "source_type": result.SourceType, "status": result.ConfirmationStatus, "version": result.Version}, }) }) return result, err } func (s *Service) PatchSubjectFact(ctx context.Context, principal identity.Principal, subjectPublicID, factPublicID, requestID string, expectedVersion int64, input PatchFactInput) (SubjectFact, error) { var result SubjectFact 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 } var current SubjectFact current, err = scanFact(tx.QueryRow(ctx, ` SELECT f.public_id::text,$1::text,f.fact_type,f.content,f.source_type,COALESCE(f.source_ref,''), f.confirmation_status,f.sensitivity,f.valid_from,f.valid_to,f.version,f.created_at,f.updated_at FROM subject_fact f WHERE f.public_id=$2::uuid AND f.tenant_id=$3::uuid AND f.subject_id=$4::uuid FOR UPDATE`, subjectPublicID, factPublicID, access.tenantID, access.subjectID)) if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } if err != nil { return fmt.Errorf("read subject fact for update: %w", err) } if current.Version != expectedVersion { return ErrVersionConflict } factType, content, sourceRef, status, sensitivity := current.FactType, current.Content, current.SourceRef, current.ConfirmationStatus, current.Sensitivity validFrom, validTo := current.ValidFrom, current.ValidTo if input.FactType != nil { factType = *input.FactType } if input.Content != nil { content = *input.Content } if input.SourceRef != nil { sourceRef = *input.SourceRef } if input.ConfirmationStatus != nil { status = *input.ConfirmationStatus } if input.Sensitivity != nil { sensitivity = *input.Sensitivity } if input.ValidFrom != nil { validFrom = input.ValidFrom } if input.ValidTo != nil { validTo = input.ValidTo } if status == "confirmed" && sourceRef == "" { return ErrFactSourceRequired } result, err = scanFact(tx.QueryRow(ctx, ` UPDATE subject_fact SET fact_type=$1,content=$2,source_ref=NULLIF($3,''),confirmation_status=$4, sensitivity=$5,valid_from=$6,valid_to=$7,updated_by=$8::uuid,version=version+1,updated_at=now() WHERE id=(SELECT id FROM subject_fact WHERE public_id=$9::uuid) AND tenant_id=$10::uuid RETURNING public_id::text,$11::text,fact_type,content,source_type,COALESCE(source_ref,''), confirmation_status,sensitivity,valid_from,valid_to,version,created_at,updated_at`, factType, content, sourceRef, status, sensitivity, validFrom, validTo, access.userID, factPublicID, access.tenantID, subjectPublicID)) if err != nil { return fmt.Errorf("update subject fact: %w", err) } return s.audit.Record(ctx, tx, audit.Event{ TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "subject_fact.update", ResourceType: "subject_fact", ResourceID: strPtr(result.ID), RequestID: requestID, Result: "success", Detail: map[string]any{"subject_id": subjectPublicID, "from_version": expectedVersion, "to_version": result.Version, "status": result.ConfirmationStatus}, }) }) return result, err } var ErrFactSourceRequired = errors.New("confirmed fact requires a source reference") func scanFact(row rowScanner) (SubjectFact, error) { var item SubjectFact if err := row.Scan(&item.ID, &item.SubjectID, &item.FactType, &item.Content, &item.SourceType, &item.SourceRef, &item.ConfirmationStatus, &item.Sensitivity, &item.ValidFrom, &item.ValidTo, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil { return SubjectFact{}, err } return item, nil }