package saas import ( "context" "errors" "fmt" "strings" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/identity" "github.com/jackc/pgx/v5" ) const ( DeviceBindingActionUnbind = "unbind" DeviceBindingActionTransfer = "transfer" ) type DeviceBindingImpact struct { DeviceID string `json:"device_id"` BindingID string `json:"binding_id"` Action string `json:"action"` SourceHouseholdID string `json:"source_household_id"` SourceSubjectID string `json:"source_subject_id"` SourceSubjectName string `json:"source_subject_name"` TargetHouseholdID *string `json:"target_household_id,omitempty"` TargetSubjectID *string `json:"target_subject_id,omitempty"` TargetSubjectName *string `json:"target_subject_name,omitempty"` ActiveConversationID *string `json:"active_conversation_id,omitempty"` PendingCommandCount int `json:"pending_command_count"` ActiveFirmwareTaskCount int `json:"active_firmware_task_count"` Allowed bool `json:"allowed"` Blockers []string `json:"blockers"` EvaluatedAt time.Time `json:"evaluated_at"` } type ChangeDeviceBindingInput struct { Action string ExpectedBindingID string TargetSubjectID string Reason string Acknowledged bool } type DeviceBindingChangeResult struct { DeviceID string `json:"device_id"` Action string `json:"action"` PreviousBindingID string `json:"previous_binding_id"` BindingID *string `json:"binding_id,omitempty"` HouseholdID *string `json:"household_id,omitempty"` SubjectID *string `json:"subject_id,omitempty"` ChangedAt time.Time `json:"changed_at"` } func (s *Service) GetDeviceBindingImpact(ctx context.Context, principal identity.Principal, devicePublicID, action, targetSubjectPublicID string) (DeviceBindingImpact, error) { access, device, err := resolveDevice(ctx, s.pool, principal, devicePublicID, identity.CapabilityDeviceManage, false) if err != nil { return DeviceBindingImpact{}, err } return s.readDeviceBindingImpact(ctx, s.pool, principal, access, device, action, targetSubjectPublicID) } func (s *Service) ChangeDeviceBinding(ctx context.Context, principal identity.Principal, devicePublicID, requestID string, input ChangeDeviceBindingInput) (DeviceBindingChangeResult, error) { var result DeviceBindingChangeResult err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { access, device, err := resolveDevice(ctx, tx, principal, devicePublicID, identity.CapabilityDeviceManage, true) if err != nil { return err } var bindingID string if err := tx.QueryRow(ctx, `SELECT public_id::text FROM device_binding WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND unbound_at IS NULL FOR UPDATE`, access.deviceID, access.tenantID).Scan(&bindingID); errors.Is(err, pgx.ErrNoRows) { return ErrDeviceBindingStale } else if err != nil { return fmt.Errorf("lock active device binding: %w", err) } if bindingID != input.ExpectedBindingID { return ErrDeviceBindingStale } impact, err := s.readDeviceBindingImpact(ctx, tx, principal, access, device, input.Action, input.TargetSubjectID) if err != nil { return err } if !impact.Allowed { return ErrDeviceBindingBlocked } now := time.Now().UTC() if _, err := tx.Exec(ctx, `UPDATE device_binding SET unbound_at=$1,unbound_by=$2::uuid,reason=$3 WHERE public_id=$4::uuid AND unbound_at IS NULL`, now, access.userID, input.Action+":"+strings.TrimSpace(input.Reason), bindingID); err != nil { return fmt.Errorf("close active device binding: %w", err) } result = DeviceBindingChangeResult{DeviceID: devicePublicID, Action: input.Action, PreviousBindingID: bindingID, ChangedAt: now} if input.Action == DeviceBindingActionTransfer { targetAccess, _, err := resolveSubject(ctx, tx, principal, input.TargetSubjectID, identity.CapabilityDeviceManage, true) if err != nil || targetAccess.tenantID != access.tenantID { return ErrAccessDenied } var newBindingID string if err := tx.QueryRow(ctx, ` INSERT INTO device_binding (tenant_id,device_id,household_id,subject_id,bound_by,reason,bound_at) VALUES ($1::uuid,$2::uuid,$3::uuid,$4::uuid,$5::uuid,$6,$7) RETURNING public_id::text`, access.tenantID, access.deviceID, targetAccess.householdID, targetAccess.subjectID, access.userID, "transfer:"+strings.TrimSpace(input.Reason), now).Scan(&newBindingID); err != nil { return fmt.Errorf("create transferred device binding: %w", err) } result.BindingID = &newBindingID result.HouseholdID = &targetAccess.householdPublicID result.SubjectID = &input.TargetSubjectID } return s.audit.Record(ctx, tx, audit.Event{ TenantID: strPtr(access.tenantID), ActorType: "user", ActorPublicID: strPtr(access.userPublicID), Action: "device.binding." + input.Action, ResourceType: "device", ResourceID: strPtr(devicePublicID), RequestID: requestID, Result: "success", Detail: map[string]any{ "previous_binding_id": bindingID, "new_binding_id": result.BindingID, "target_household_id": result.HouseholdID, "target_subject_id": result.SubjectID, "reason": strings.TrimSpace(input.Reason), }, }) }) return result, err } func (s *Service) readDeviceBindingImpact(ctx context.Context, q queryRow, principal identity.Principal, access deviceAccess, device ManagedDevice, action, targetSubjectPublicID string) (DeviceBindingImpact, error) { if action != DeviceBindingActionUnbind && action != DeviceBindingActionTransfer { return DeviceBindingImpact{}, ErrNotFound } impact := DeviceBindingImpact{ DeviceID: device.ID, BindingID: device.BindingID, Action: action, SourceHouseholdID: device.HouseholdID, SourceSubjectID: device.SubjectID, SourceSubjectName: device.SubjectName, Blockers: make([]string, 0), EvaluatedAt: time.Now().UTC(), } if action == DeviceBindingActionTransfer { if targetSubjectPublicID == "" || targetSubjectPublicID == device.SubjectID { impact.Blockers = append(impact.Blockers, "target_subject_required") } else { targetAccess, target, err := resolveSubject(ctx, q, principal, targetSubjectPublicID, identity.CapabilityDeviceManage, false) if err != nil || targetAccess.tenantID != access.tenantID { return DeviceBindingImpact{}, ErrAccessDenied } impact.TargetHouseholdID = &target.HouseholdID impact.TargetSubjectID = &target.ID impact.TargetSubjectName = &target.PreferredName } } var activeConversation string err := q.QueryRow(ctx, `SELECT public_id::text FROM conversation WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status NOT IN ('COMPLETED','FAILED','EXPIRED') ORDER BY created_at DESC LIMIT 1`, access.deviceID, access.tenantID).Scan(&activeConversation) if err == nil { impact.ActiveConversationID = &activeConversation impact.Blockers = append(impact.Blockers, "active_conversation") } else if !errors.Is(err, pgx.ErrNoRows) { return DeviceBindingImpact{}, fmt.Errorf("check active device conversation: %w", err) } if err := q.QueryRow(ctx, `SELECT count(*) FROM device_command WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('QUEUED','DELIVERED','ACKNOWLEDGED') AND expires_at>now()`, access.deviceID, access.tenantID).Scan(&impact.PendingCommandCount); err != nil { return DeviceBindingImpact{}, fmt.Errorf("count pending device commands: %w", err) } if impact.PendingCommandCount > 0 { impact.Blockers = append(impact.Blockers, "pending_device_commands") } if err := q.QueryRow(ctx, `SELECT count(*) FROM firmware_assignment WHERE device_id=$1::uuid AND tenant_id=$2::uuid AND status IN ('PENDING','COMMAND_QUEUED','DOWNLOADING','INSTALLING')`, access.deviceID, access.tenantID).Scan(&impact.ActiveFirmwareTaskCount); err != nil { return DeviceBindingImpact{}, fmt.Errorf("count active firmware assignments: %w", err) } if impact.ActiveFirmwareTaskCount > 0 { impact.Blockers = append(impact.Blockers, "active_firmware_task") } impact.Allowed = len(impact.Blockers) == 0 return impact, nil }